Skip to content

Commit 1296e84

Browse files
authored
Merge pull request #19 from jfro/jtk/oidc-auth
OIDC Auth support
2 parents 86ab807 + dc590e9 commit 1296e84

File tree

11 files changed

+331
-25
lines changed

11 files changed

+331
-25
lines changed

.env.sample

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,10 @@ MAILER_ADAPTER="mailgun"
1111
MAILGUN_API_KEY="apikey"
1212
MAILGUN_DOMAIN="mailer.example.com"
1313
EMAIL_FROM_ADDRESS="fuzzy_catalog@example.com"
14+
15+
# OIDC Configuration (optional)
16+
# Configure these to enable OIDC authentication
17+
OIDC_CLIENT_ID=""
18+
OIDC_CLIENT_SECRET=""
19+
OIDC_BASE_URL=""
20+
OIDC_REDIRECT_URI="http://localhost:4000/auth/oidc/callback"

config/runtime.exs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,4 +202,12 @@ if config_env() == :prod do
202202
from_address: System.get_env("EMAIL_FROM_ADDRESS", "noreply@localhost")
203203
end
204204

205+
# Configure OIDC
206+
config :fuzzy_catalog, :oidc,
207+
client_id: System.get_env("OIDC_CLIENT_ID"),
208+
client_secret: System.get_env("OIDC_CLIENT_SECRET"),
209+
base_url: System.get_env("OIDC_BASE_URL"),
210+
redirect_uri: System.get_env("OIDC_REDIRECT_URI") || "http://localhost:4000/auth/oidc/callback",
211+
authorization_params: [scope: "openid profile email"]
212+
205213
# end if prod

lib/fuzzy_catalog/accounts.ex

Lines changed: 49 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,23 @@ defmodule FuzzyCatalog.Accounts do
2626
Repo.get_by(User, email: email)
2727
end
2828

29+
@doc """
30+
Gets a user by provider and provider UID.
31+
32+
## Examples
33+
34+
iex> get_user_by_provider("oidc", "123456")
35+
%User{}
36+
37+
iex> get_user_by_provider("oidc", "unknown")
38+
nil
39+
40+
"""
41+
def get_user_by_provider(provider, provider_uid)
42+
when is_binary(provider) and is_binary(provider_uid) do
43+
Repo.get_by(User, provider: provider, provider_uid: provider_uid)
44+
end
45+
2946
@doc """
3047
Gets a user by email and password.
3148
@@ -113,6 +130,38 @@ defmodule FuzzyCatalog.Accounts do
113130
|> Repo.insert()
114131
end
115132

133+
@doc """
134+
Creates a user from a changeset.
135+
"""
136+
def create_user_from_changeset(changeset) do
137+
Repo.insert(changeset)
138+
end
139+
140+
@doc """
141+
Returns true if there are no users in the system.
142+
"""
143+
def first_user? do
144+
Repo.aggregate(User, :count) == 0
145+
end
146+
147+
@doc """
148+
Ensures the first user in the system is marked as admin.
149+
This function should be called after user creation.
150+
"""
151+
def ensure_first_user_is_admin do
152+
import Ecto.Query
153+
# Get the first user (oldest by insertion)
154+
first_user = Repo.one(from u in User, order_by: [asc: u.inserted_at], limit: 1)
155+
156+
if first_user && first_user.role != "admin" do
157+
first_user
158+
|> User.admin_changeset(%{role: "admin"})
159+
|> Repo.update()
160+
else
161+
{:ok, first_user}
162+
end
163+
end
164+
116165
## Settings
117166

118167
@doc """
@@ -383,30 +432,6 @@ defmodule FuzzyCatalog.Accounts do
383432
User.admin_changeset(user, attrs)
384433
end
385434

386-
@doc """
387-
Ensures the first user gets admin role.
388-
"""
389-
def ensure_first_user_is_admin do
390-
case Repo.aggregate(User, :count, :id) do
391-
0 ->
392-
:no_users
393-
394-
1 ->
395-
case Repo.one(from u in User, limit: 1) do
396-
%User{role: "admin"} = user ->
397-
{:ok, user}
398-
399-
%User{} = user ->
400-
user
401-
|> User.admin_changeset(%{role: "admin"})
402-
|> Repo.update()
403-
end
404-
405-
_ ->
406-
:multiple_users
407-
end
408-
end
409-
410435
## Token helper
411436

412437
defp update_user_and_delete_all_tokens(changeset) do

lib/fuzzy_catalog/accounts/user.ex

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ defmodule FuzzyCatalog.Accounts.User do
1010
field :authenticated_at, :utc_datetime, virtual: true
1111
field :role, :string, default: "user"
1212
field :status, :string, default: "active"
13+
field :provider, :string
14+
field :provider_uid, :string
15+
field :provider_token, :string
1316

1417
timestamps(type: :utc_datetime)
1518
end
@@ -165,4 +168,23 @@ defmodule FuzzyCatalog.Accounts.User do
165168
"""
166169
def active?(%__MODULE__{status: "active"}), do: true
167170
def active?(_), do: false
171+
172+
@doc """
173+
A user changeset for OIDC registration.
174+
"""
175+
def oidc_registration_changeset(user, attrs, opts \\ []) do
176+
user
177+
|> cast(attrs, [:email, :provider, :provider_uid, :provider_token, :role, :status])
178+
|> validate_required([:email, :provider, :provider_uid])
179+
|> validate_email(opts)
180+
|> validate_inclusion(:role, ["user", "admin"])
181+
|> validate_inclusion(:status, ["active", "disabled"])
182+
|> unique_constraint([:provider, :provider_uid])
183+
end
184+
185+
@doc """
186+
Returns true if the user was created via OIDC.
187+
"""
188+
def oidc_user?(%__MODULE__{provider: provider}) when not is_nil(provider), do: true
189+
def oidc_user?(_), do: false
168190
end
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
defmodule FuzzyCatalogWeb.OIDCController do
2+
use FuzzyCatalogWeb, :controller
3+
require Logger
4+
5+
alias FuzzyCatalog.Accounts
6+
alias FuzzyCatalog.Accounts.User
7+
alias FuzzyCatalogWeb.UserAuth
8+
9+
def authorize(conn, _params) do
10+
config = Application.get_env(:fuzzy_catalog, :oidc)
11+
12+
Logger.info("OIDC authorize initiated with config: #{inspect(config, pretty: true)}")
13+
14+
case Assent.Strategy.OIDC.authorize_url(config) do
15+
{:ok, %{url: url, session_params: session_params}} ->
16+
Logger.info("OIDC authorize successful, redirecting to: #{url}")
17+
Logger.info("Session params to store: #{inspect(session_params, pretty: true)}")
18+
19+
conn
20+
|> put_session(:oidc_state, session_params["state"])
21+
|> put_session(:oidc_nonce, session_params["nonce"])
22+
|> put_session(:oidc_session_params, session_params) # Store full session params as backup
23+
|> redirect(external: url)
24+
25+
{:error, error} ->
26+
Logger.error("OIDC authorize failed: #{inspect(error, pretty: true)}")
27+
conn
28+
|> put_flash(:error, "Authentication service is currently unavailable. Please try again later or contact support.")
29+
|> redirect(to: ~p"/users/log-in")
30+
end
31+
end
32+
33+
def callback(conn, params) do
34+
config = Application.get_env(:fuzzy_catalog, :oidc)
35+
36+
# Try to get session params from different sources
37+
state = get_session(conn, :oidc_state)
38+
nonce = get_session(conn, :oidc_nonce)
39+
stored_session_params = get_session(conn, :oidc_session_params)
40+
41+
Logger.info("OIDC callback received with params: #{inspect(Map.drop(params, ["code"]), pretty: true)}")
42+
Logger.info("Retrieved session state: #{inspect(state)}")
43+
Logger.info("Retrieved session nonce: #{inspect(nonce)}")
44+
Logger.info("Stored session params: #{inspect(stored_session_params, pretty: true)}")
45+
46+
# Use stored session params if individual values are missing, or fallback to params
47+
session_params = cond do
48+
state && nonce ->
49+
%{
50+
"state" => state,
51+
"nonce" => nonce
52+
}
53+
stored_session_params ->
54+
# Convert atom keys to string keys if needed
55+
stored_session_params
56+
|> Enum.map(fn
57+
{k, v} when is_atom(k) -> {Atom.to_string(k), v}
58+
{k, v} -> {k, v}
59+
end)
60+
|> Enum.into(%{})
61+
params["state"] ->
62+
# Fallback: use state from callback params if session is lost
63+
Logger.warning("Using state from callback params as fallback - session may have been lost")
64+
%{
65+
"state" => params["state"],
66+
"nonce" => nil
67+
}
68+
true ->
69+
%{}
70+
end
71+
72+
Logger.info("Final session params for callback: #{inspect(session_params, pretty: true)}")
73+
74+
# Check if we have required state parameter
75+
state_value = session_params["state"]
76+
if is_nil(state_value) do
77+
Logger.error("OIDC callback missing state parameter - possible session issue")
78+
conn
79+
|> put_flash(:error, "Authentication session expired. Please try signing in again.")
80+
|> redirect(to: ~p"/users/log-in")
81+
else
82+
# Convert session_params to atom keys for Assent compatibility
83+
session_params_atoms = session_params
84+
|> Enum.map(fn
85+
{"state", v} -> {:state, v}
86+
{"nonce", v} -> {:nonce, v}
87+
{k, v} -> {String.to_atom(k), v}
88+
end)
89+
|> Enum.into(%{})
90+
91+
# Merge session_params into config for Assent
92+
config_with_session = Keyword.put(config, :session_params, session_params_atoms)
93+
Logger.info("Config with session params (atom keys): #{inspect(config_with_session, pretty: true)}")
94+
95+
case Assent.Strategy.OIDC.callback(config_with_session, params) do
96+
{:ok, %{user: user_info, token: token}} ->
97+
Logger.info("OIDC callback successful for user: #{inspect(user_info["email"])}")
98+
handle_successful_auth(conn, user_info, token)
99+
100+
{:error, error} ->
101+
Logger.error("OIDC callback failed: #{inspect(error, pretty: true)}")
102+
103+
error_message = case error do
104+
%Assent.MissingConfigError{key: :session_params} ->
105+
"Authentication session expired. Please try signing in again."
106+
%{error: "invalid_grant"} ->
107+
"Authentication expired or invalid. Please try signing in again."
108+
%{error: "access_denied"} ->
109+
"Access was denied. Please try again or contact support."
110+
%{error: error_type} when is_binary(error_type) ->
111+
"Authentication failed: #{String.replace(error_type, "_", " ")}. Please try again."
112+
_ ->
113+
"Authentication failed. Please try again or contact support if the problem persists."
114+
end
115+
116+
conn
117+
|> put_flash(:error, error_message)
118+
|> redirect(to: ~p"/users/log-in")
119+
end
120+
end
121+
end
122+
123+
defp handle_successful_auth(conn, user_info, token) do
124+
email = user_info["email"]
125+
provider_uid = user_info["sub"]
126+
provider = "oidc"
127+
128+
Logger.info("Handling successful OIDC auth for email: #{email}, provider_uid: #{provider_uid}")
129+
130+
case find_or_create_user(email, provider, provider_uid, token) do
131+
{:ok, user} ->
132+
Logger.info("OIDC user login successful for user ID: #{user.id}")
133+
conn
134+
|> delete_session(:oidc_state)
135+
|> delete_session(:oidc_nonce)
136+
|> delete_session(:oidc_session_params)
137+
|> UserAuth.log_in_user(user)
138+
139+
{:error, :email_taken} ->
140+
Logger.warning("OIDC login blocked - email #{email} already exists with different provider")
141+
conn
142+
|> put_flash(:error, "An account with this email already exists. Please log in with your existing account.")
143+
|> redirect(to: ~p"/users/log-in")
144+
145+
{:error, changeset} ->
146+
Logger.error("OIDC user creation failed: #{inspect(changeset.errors, pretty: true)}")
147+
conn
148+
|> put_flash(:error, "Failed to create your account. Please try again or contact support.")
149+
|> redirect(to: ~p"/users/log-in")
150+
end
151+
end
152+
153+
defp find_or_create_user(email, provider, provider_uid, token) do
154+
Logger.debug("Finding or creating user for email: #{email}, provider: #{provider}, provider_uid: #{provider_uid}")
155+
156+
case Accounts.get_user_by_provider(provider, provider_uid) do
157+
%User{} = user ->
158+
Logger.debug("Found existing OIDC user: #{user.id}")
159+
{:ok, user}
160+
161+
nil ->
162+
Logger.debug("No existing OIDC user found, checking for email conflicts")
163+
case Accounts.get_user_by_email(email) do
164+
%User{provider: nil} ->
165+
Logger.debug("Email exists with local account, blocking OIDC registration")
166+
{:error, :email_taken}
167+
168+
%User{} = user ->
169+
Logger.debug("Email exists with OIDC account, using existing user: #{user.id}")
170+
{:ok, user}
171+
172+
nil ->
173+
Logger.debug("Creating new OIDC user")
174+
create_oidc_user(email, provider, provider_uid, token)
175+
end
176+
end
177+
end
178+
179+
defp create_oidc_user(email, provider, provider_uid, token) do
180+
# Check if this is the first user (should be admin)
181+
is_first_user = Accounts.first_user?()
182+
role = if is_first_user, do: "admin", else: "user"
183+
184+
Logger.info("Creating OIDC user - first user: #{is_first_user}, role: #{role}")
185+
186+
attrs = %{
187+
email: email,
188+
provider: provider,
189+
provider_uid: provider_uid,
190+
provider_token: token["access_token"],
191+
role: role,
192+
status: "active",
193+
confirmed_at: DateTime.utc_now(:second)
194+
}
195+
196+
Logger.debug("Creating OIDC user with attrs: #{inspect(Map.drop(attrs, [:provider_token]), pretty: true)}")
197+
198+
result = %User{}
199+
|> User.oidc_registration_changeset(attrs)
200+
|> Accounts.create_user_from_changeset()
201+
202+
case result do
203+
{:ok, user} ->
204+
Logger.info("Successfully created OIDC user: #{user.id}")
205+
{:ok, user}
206+
{:error, changeset} ->
207+
Logger.error("Failed to create OIDC user: #{inspect(changeset.errors, pretty: true)}")
208+
{:error, changeset}
209+
end
210+
end
211+
end

lib/fuzzy_catalog_web/controllers/user_session_html.ex

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,9 @@ defmodule FuzzyCatalogWeb.UserSessionHTML do
66
defp local_mail_adapter? do
77
Application.get_env(:fuzzy_catalog, FuzzyCatalog.Mailer)[:adapter] == Swoosh.Adapters.Local
88
end
9+
10+
defp oidc_enabled? do
11+
config = Application.get_env(:fuzzy_catalog, :oidc, [])
12+
config[:client_id] && config[:client_secret] && config[:base_url]
13+
end
914
end

lib/fuzzy_catalog_web/controllers/user_session_html/new.html.heex

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,5 +70,16 @@
7070
Log in only this time
7171
</.button>
7272
</.form>
73+
74+
<%= if oidc_enabled?() do %>
75+
<div class="divider">or</div>
76+
77+
<.link
78+
href={~p"/auth/oidc"}
79+
class="btn btn-primary btn-outline w-full"
80+
>
81+
Sign in with OIDC <span aria-hidden="true"></span>
82+
</.link>
83+
<% end %>
7384
</div>
7485
</Layouts.app>

0 commit comments

Comments
 (0)