defmodule AuthPlug do
@moduledoc """
`AuthPlug` handles all our auth needs in just a handful of lines of code.
Please see `README.md` for setup instructions.
"""
# https://hexdocs.pm/plug/readme.html#the-plug-conn-struct
import Plug.Conn,
only: [
assign: 3,
clear_session: 1,
configure_session: 2,
delete_session: 2,
halt: 1,
put_resp_header: 3,
resp: 3
]
# https://hexdocs.pm/logger/Logger.html
require Logger
# Moch HTTPoison requests in Dev/Test, see: https://github.com/dwyl/elixir-auth-google/issues/35
@httpoison (Application.compile_env(:auth_plug, :httpoison_mock) && AuthPlug.HTTPoisonMock) ||
HTTPoison
@doc """
`init/1` initialises the options passed in and makes them
available in the lifecycle of the `call/2` invocation (below).
We pass in the `auth_url` key/value with the URL of the Auth service
to redirect to if session is invalid/expired.
"""
def init(options) do
# return options unmodified
AuthPlug.Helpers.check_environment_vars()
options
end
@doc """
`call/2` is invoked to handle each HTTP request which `auth_plug` protects.
If the `conn` contains a valid JWT in Authentication Headers,
jwt query parameter or Phoenix Session, then continue to the protected route,
else redirect to the `auth_url` with the referer set as the continuation URL.
"""
def call(conn, _options) do
jwt = AuthPlug.Token.get_jwt(conn)
case AuthPlug.Token.verify_jwt(jwt) do
{:ok, values} ->
AuthPlug.Token.put_current_token(conn, jwt, values)
# log the JWT verify error then redirect:
{:error, reason} ->
Logger.error("AuthPlug: " <> Kernel.inspect(reason))
redirect_to_auth(conn)
end
end
# redirect to auth_url with referer to resume once authenticated:
defp redirect_to_auth(conn) do
to = get_auth_url(conn)
# gotta tell the browser to temporarily redirect to the auth_url with 302
status = 302
conn
# redirect to auth_url
|> put_resp_header("location", to)
# only our tests see this.
|> resp(status, "unauthorized")
# halt the conn so no further processing is done.
|> halt()
end
# Proxy function for to avoid breaking existing apps that rely on this:
def create_jwt_session(conn, claims) do
AuthPlug.Token.create_jwt_session(conn, claims)
end
@doc """
`logout/1` does exactly what you expect; logs the person out of your app.
receives a `conn` (Plug.Conn) and unsets the session.
This is super-useful in testing as we can easily reset a session.
"""
def logout(conn) do
# stackoverflow.com/questions/42325996/delete-assigns
conn = update_in(conn.assigns, &Map.drop(&1, [:jwt, :person]))
conn
# see below. makes REST API req to auth_url/end_session
|> end_session()
# hexdocs.pm/plug/Plug.Conn.html#delete_session/2,
|> delete_session(:jwt)
# hexdocs.pm/plug/Plug.Conn.html#clear_session/1
|> clear_session()
# stackoverflow.com/questions/30999176
|> configure_session(drop: true)
|> assign(:state, "logout")
|> assign(:loggedin, false)
end
@doc """
`assign_jwt_to_socket/3` assigns a 'person' object containing information
about the authenticated person to the socket
in case the jwt parse is successful.
It raises an error if jwt is not valid.
This function is especially handy with LiveView.
Invoke this as:
socket = socket
|> AuthPlug.assign_jwt_to_socket(&Phoenix.LiveView.assign_new/3, jwt)
`socket` is the first argument to `assign_jwt_to_socket/3` so it's chainable.
"""
def assign_jwt_to_socket(socket, assign_new, jwt) do
claims =
jwt
|> AuthPlug.Token.verify_jwt!()
|> AuthPlug.Helpers.strip_struct_metadata()
|> Useful.atomize_map_keys()
socket =
socket
# Pass function by reference in Elixir:
# stackoverflow.com/a/22562288/1148249
|> assign_new.(:person, fn -> claims end)
|> assign_new.(:loggedin, fn -> true end)
socket
end
# `parse_body_response/1` parses the REST HTTP response
# so your app can use the resulting JSON.
defp parse_body_response(response) do
body = Map.get(response, :body)
{:ok, str_key_map} = Jason.decode(body)
{:ok, Useful.atomize_map_keys(str_key_map)}
end
# Send query to auth app to end session.
# Returns tuple with status code and message
def end_session_auth(auth_url) do
with {:ok, response} <- @httpoison.post(auth_url, ''),
{:status_code, 200} <- {:status_code, response.status_code} do
{:ok, res} = parse_body_response(response)
{200, res.message}
else
{:status_code, status_code} ->
{status_code, "status code: #{status_code}"}
{:error, _httpoison_error} ->
{400, "The request to the auth app failed"}
end
end
@doc """
`end_session/1` makes an HTTP Request to the auth_url
to end the session. This in turn makes the update on the auth app
to update the session.end so the owner of the "consumer" app
knows when the person logged out.
`end_session/1` is invoked by `AuthPlug.logout/1` (above)
which will likely be the function called in practice.
"""
def end_session(conn) do
auth_url = AuthPlug.Token.auth_url()
client_id = AuthPlug.Token.client_id()
jwt = AuthPlug.Token.get_jwt(conn)
{:ok, claims_strs} = AuthPlug.Token.verify_jwt(jwt)
claims = Useful.atomize_map_keys(claims_strs)
{status_code, message} =
end_session_auth("#{auth_url}/end_session/#{client_id}/#{claims.sid}/")
resp(conn, status_code, message)
end
@doc """
`get_auth_url/2` returns a string representing
the auth url.
The first parameter is `conn`,
the second is optional and represents
the endpoint in your application where the auth application will
redirect to after authentication.
By default the second parameter value is `conn.request_path` which represents
the current path.
## Examples
iex> AuthPlug.get_auth_url(conn)
"https://dwylauth.herokuapp.com/?referer=https://www.example.com/&auth_client_id=123123"
iex> AuthPlug.get_auth_url(conn, "/mypage)
"https://dwylauth.herokuapp.com/?referer=https://www.example.com/mypage&auth_client_id=123123"
"""
def get_auth_url(conn, redirect_to \\ nil) do
auth_url = AuthPlug.Token.auth_url()
request_path = redirect_to || conn.request_path
referer =
conn
|> AuthPlug.Helpers.get_baseurl_from_conn()
|> Kernel.<>(request_path)
|> URI.encode()
client_id = AuthPlug.Token.client_id()
"#{auth_url}?referer=#{referer}&auth_client_id=#{client_id}"
end
end