defmodule OpenAgentsWeb.AuthController do
use OpenAgentsWeb, :controller
alias OpenAgents.{Accounts, Analytics, DeviceAuthorizations, GitHubOAuth, Repositories}
@attempt_session_key "github_oauth_attempt"
@identity_session_key "posthog_identity"
def start(conn, _params) do
case GitHubOAuth.begin_authorization() do
{:ok, attempt, authorization_url} ->
Analytics.capture("auth_started", Analytics.browser_distinct_id(conn))
conn
|> put_resp_header("cache-control", "no-store")
|> put_session(@attempt_session_key, attempt)
|> redirect(external: authorization_url)
{:error, _reason} ->
auth_failure(conn, "unavailable")
end
end
def callback(conn, %{"code" => code, "state" => state}) do
attempt = get_session(conn, @attempt_session_key)
verifier = if is_map(attempt), do: attempt["verifier"]
# Read before the session is cleared below, because clearing it is what
# takes the remembered device code away.
landing = landing_path(get_session(conn, OpenAgentsWeb.UserAuth.device_session_key()))
conn = delete_session(conn, @attempt_session_key)
with :ok <- GitHubOAuth.consume_attempt(attempt, state),
{:ok, profile, access_token, granted_scopes} <-
GitHubOAuth.exchange_and_fetch(code, verifier),
{:ok, user} <- Accounts.upsert_github_user(profile),
{:ok, active_user} <- Accounts.get_active_user(user.id),
{:ok, _namespace} <- Repositories.ensure_user_namespace(active_user),
{:ok, _stored} <-
Accounts.store_github_token(active_user, access_token, granted_scopes) do
capture_sign_in(active_user)
conn
|> clear_session()
|> configure_session(renew: true)
|> put_session("user_id", active_user.id)
|> put_session(@identity_session_key, identity(active_user))
|> put_resp_header("cache-control", "no-store")
|> redirect(to: landing)
else
{:error, :banned} -> auth_failure(conn, "banned")
{:error, _reason} -> auth_failure(conn, "failed")
end
end
def callback(conn, %{"error" => _provider_error}) do
conn = delete_session(conn, @attempt_session_key)
auth_failure(conn, "denied")
end
def callback(conn, _params) do
conn = delete_session(conn, @attempt_session_key)
auth_failure(conn, "failed")
end
def logout(conn, _params) do
case get_session(conn, @identity_session_key) do
%{"distinct_id" => distinct_id} when is_binary(distinct_id) ->
Analytics.capture("user_logged_out", distinct_id)
_absent ->
:ok
end
conn
|> clear_session()
|> configure_session(drop: true)
|> put_resp_header("cache-control", "no-store")
|> redirect(to: ~p"/")
end
def disconnect(conn, _params) do
case Accounts.disconnect_github(conn.assigns.current_user) do
{:ok, _user} ->
conn
|> put_flash(:info, "GitHub tools disconnected and the retained grant was revoked.")
|> put_resp_header("cache-control", "no-store")
|> redirect(to: ~p"/sarah")
{:error, _reason} ->
conn
|> put_flash(:error, "GitHub tools could not be disconnected. Try again.")
|> put_resp_header("cache-control", "no-store")
|> redirect(to: ~p"/sarah")
end
end
# Where a completed sign-in lands.
#
# A reader who came from `/device` is halfway through authorizing a terminal,
# not starting a session at the dashboard. `OpenAgentsWeb.UserAuth` remembers
# the terminal's code when it bounces them here, and this returns them to the
# approval with the code still in hand, so approving is the next click rather
# than a fresh errand.
#
# The code is cast a second time on the way out. The session is signed, so
# this is not defending against a forged cookie; it is keeping one rule —
# only a value this application mints ever becomes part of a URL — true at
# every place that builds one, rather than true here because it happened to
# be checked somewhere else.
#
# Nothing writes the key back. It has done its work, and `clear_session/1`
# above takes it with the rest.
defp landing_path(code) do
case DeviceAuthorizations.cast_user_code(code) do
{:ok, code} -> ~p"/device?user_code=#{code}"
:error -> ~p"/sarah"
end
end
# A row created and updated in the same write is a first sign-in; anything
# else reauthenticated an existing account.
defp capture_sign_in(user) do
event =
if DateTime.compare(user.inserted_at, user.updated_at) == :eq,
do: "user_signed_up",
else: "user_signed_in"
Analytics.capture(event, Analytics.distinct_id(user), %{
"github_login" => user.github_login
})
end
defp identity(user) do
%{"distinct_id" => Analytics.distinct_id(user), "login" => user.github_login}
end
defp auth_failure(conn, code) do
Analytics.capture("auth_failed", Analytics.browser_distinct_id(conn), %{"reason" => code})
conn
|> clear_session()
|> configure_session(renew: true)
|> put_resp_header("cache-control", "no-store")
|> redirect(to: ~p"/?auth_error=#{code}")
end
end