Fix OIDC Loop and seed rauthy dev setup closes #510 #513

Merged
moritz merged 3 commits from rauthy_setup into main 2026-06-01 20:39:07 +02:00
12 changed files with 312 additions and 22 deletions

View file

@ -24,7 +24,7 @@ ASSOCIATION_NAME="Sportsclub XYZ"
# OIDC_CLIENT_ID=mv # OIDC_CLIENT_ID=mv
# OIDC_BASE_URL=http://localhost:8080/auth/v1 # OIDC_BASE_URL=http://localhost:8080/auth/v1
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/oidc/callback # OIDC_REDIRECT_URI=http://localhost:4001/auth/user/oidc/callback
# OIDC_CLIENT_SECRET=your-oidc-client-secret # OIDC_CLIENT_SECRET=mv-dev-shared-secret-not-for-production-do-not-use-anywhere-else
# Optional: OIDC group → Admin role sync (e.g. Authentik groups from profile scope) # Optional: OIDC group → Admin role sync (e.g. Authentik groups from profile scope)
# If OIDC_ADMIN_GROUP_NAME is set, users in that group get Admin role on registration/sign-in. # If OIDC_ADMIN_GROUP_NAME is set, users in that group get Admin role on registration/sign-in.

View file

@ -124,8 +124,8 @@ mix archive.install hex phx_new
1. Copy env file: 1. Copy env file:
```bash ```bash
cp .env.example .env cp .env.example .env
# Set OIDC_CLIENT_SECRET inside .env
``` ```
The dev `OIDC_CLIENT_SECRET` is already preset — no manual GUI step needed.
2. Start everything (database, Mailcrab, Rauthy, app): 2. Start everything (database, Mailcrab, Rauthy, app):
```bash ```bash
@ -139,21 +139,9 @@ mix archive.install hex phx_new
## 🔐 Testing SSO locally ## 🔐 Testing SSO locally
Mila uses OIDC for Single Sign-On. In development, a local **Rauthy** instance is provided. A local **Rauthy** instance is provided in dev. The `mv` client is auto-seeded from `rauthy-bootstrap/clients.json` on first start (and after `docker compose down -v`), so the secret in `.env.example` always matches.
1. `just run` Rauthy admin UI: <http://localhost:8080> — login `admin@localhost`, password from `BOOTSTRAP_ADMIN_PASSWORD_PLAIN` in `docker-compose.yml`.
2. go to [localhost:8080](http://localhost:8080), go to the Admin area
3. Login with "admin@localhost" and password from `BOOTSTRAP_ADMIN_PASSWORD_PLAIN` in docker-compose.yml
4. add client from the admin panel
- Client ID: mv
- redirect uris: http://localhost:4000/auth/user/oidc/callback
- Authorization Flows: authorization_code
- allowed origins: http://localhost:4000
- access/id token algortihm: RS256 (EDDSA did not work for me, found just few infos in the ashauthentication docs)
5. copy client secret to `.env` file
6. abort and run `just run` again
Now you can log in to Mila via OIDC!
### OIDC with other providers (Authentik, Keycloak, etc.) ### OIDC with other providers (Authentik, Keycloak, etc.)

View file

@ -36,6 +36,9 @@ services:
- BOOTSTRAP_ADMIN_PASSWORD_PLAIN=RauthyTest12345 - BOOTSTRAP_ADMIN_PASSWORD_PLAIN=RauthyTest12345
# Disable strict IP validation to allow access from multiple Docker networks # Disable strict IP validation to allow access from multiple Docker networks
- SESSION_VALIDATE_IP=false - SESSION_VALIDATE_IP=false
# Auto-seed the `mv` OIDC client (id + plain secret) on first DB init.
# Re-runs after `docker compose down -v` because the DB is empty again.
- BOOTSTRAP_DIR=/app/bootstrap
ports: ports:
- "8080:8080" - "8080:8080"
depends_on: depends_on:
@ -46,6 +49,7 @@ services:
- local - local
volumes: volumes:
- rauthy-data:/app/data - rauthy-data:/app/data
- ./rauthy-bootstrap:/app/bootstrap:ro
volumes: volumes:
postgres-data: postgres-data:

88
lib/mv/oidc/discovery.ex Normal file
View file

@ -0,0 +1,88 @@
defmodule Mv.Oidc.Discovery do
@moduledoc """
Fetches and caches the OIDC provider's discovery document
(`/.well-known/openid-configuration`).
Currently only `end_session_endpoint` is exposed used by the logout flow to
trigger RP-initiated logout at the IdP so the user's SSO session is cleared
and they don't get auto-re-logged-in.
Cache lives in `:persistent_term`, keyed by base URL, for the lifetime of the
BEAM. Re-fetch on next call after `clear_cache/0`.
"""
require Logger
@persistent_term_key {__MODULE__, :discovery}
@request_timeout 5_000
@doc """
Returns the IdP's `end_session_endpoint` URL.
- `{:ok, url}` if discovery succeeds (and is cached for future calls)
- `{:error, reason}` if the IdP is unreachable, the document is malformed,
or the field is missing
"""
@spec end_session_endpoint(String.t()) :: {:ok, String.t()} | {:error, term()}
def end_session_endpoint(base_url) when is_binary(base_url) do
case fetch_cached(base_url) do
{:ok, %{"end_session_endpoint" => url}} when is_binary(url) -> {:ok, url}
{:ok, _config} -> {:error, :no_end_session_endpoint}
{:error, _} = err -> err
end
end
@doc """
Clears the cached discovery documents. Intended for tests.
"""
@spec clear_cache() :: :ok
def clear_cache do
:persistent_term.erase(@persistent_term_key)
:ok
end
@doc """
Seeds the cache with a fixed result for a base URL. Intended for tests so the
HTTP fetch is skipped.
"""
@spec put_cache(String.t(), {:ok, map()} | {:error, term()}) :: :ok
def put_cache(base_url, result) when is_binary(base_url) do
cache = :persistent_term.get(@persistent_term_key, %{})
:persistent_term.put(@persistent_term_key, Map.put(cache, base_url, result))
:ok
end
defp fetch_cached(base_url) do
cache = :persistent_term.get(@persistent_term_key, %{})
case Map.fetch(cache, base_url) do
{:ok, result} ->
result
:error ->
result = fetch(base_url)
:persistent_term.put(@persistent_term_key, Map.put(cache, base_url, result))
result
end
end
defp fetch(base_url) do
url = String.trim_trailing(base_url, "/") <> "/.well-known/openid-configuration"
case Req.get(url,
receive_timeout: @request_timeout,
connect_options: [timeout: @request_timeout]
) do
{:ok, %Req.Response{status: 200, body: body}} when is_map(body) ->
{:ok, body}
{:ok, %Req.Response{status: status}} ->
Logger.warning("OIDC discovery returned HTTP #{status} for #{url}")
{:error, {:http_status, status}}
{:error, reason} ->
Logger.warning("OIDC discovery request failed for #{url}: #{inspect(reason)}")
{:error, reason}
end
end
end

View file

@ -16,6 +16,7 @@ defmodule MvWeb.AuthController do
alias Mv.Accounts.User.Errors.PasswordVerificationRequired alias Mv.Accounts.User.Errors.PasswordVerificationRequired
alias Mv.Config alias Mv.Config
alias Mv.Oidc.Discovery
def success(conn, {:password, :sign_in} = _activity, user, token) do def success(conn, {:password, :sign_in} = _activity, user, token) do
if Config.oidc_only?() do if Config.oidc_only?() do
@ -337,11 +338,28 @@ defmodule MvWeb.AuthController do
defp redact_url(_), do: "[redacted]" defp redact_url(_), do: "[redacted]"
def sign_out(conn, _params) do def sign_out(conn, _params) do
return_to = get_session(conn, :return_to) || ~p"/" conn = clear_session(conn, :mv) |> put_flash(:success, gettext("You are now signed out"))
conn case oidc_end_session_url() do
|> clear_session(:mv) {:ok, url} ->
|> put_flash(:success, gettext("You are now signed out")) redirect(conn, external: url)
|> redirect(to: return_to)
:no_oidc ->
redirect(conn, to: get_session(conn, :return_to) || ~p"/")
{:error, _reason} ->
# IdP discovery failed — fall back to local logout. The user's IdP session
# is still active, so OIDC_ONLY setups may auto-re-login. Better than
# blocking logout entirely.
redirect(conn, to: ~p"/sign-in?oidc_failed=1")
end
end
defp oidc_end_session_url do
if Config.oidc_configured?() do
Discovery.end_session_endpoint(Config.oidc_base_url())
else
:no_oidc
end
end end
end end

View file

@ -0,0 +1,62 @@
defmodule MvWeb.SignOutLive do
@moduledoc """
Custom sign-out confirmation page.
Replaces AshAuthentication.Phoenix.SignOutLive so the page meets accessibility
requirements (main landmark via Layouts.public_page, level-one heading) and
uses the project's DaisyUI button styles. Submits DELETE /sign-out for CSRF
protection, same contract as the library default.
"""
use Phoenix.LiveView
use Gettext, backend: MvWeb.Gettext
alias Mv.Membership
alias MvWeb.Layouts
@impl true
def mount(_params, session, socket) do
locale = session["locale"] || Application.get_env(:mv, :default_locale, "de")
Gettext.put_locale(MvWeb.Gettext, locale)
Gettext.put_locale(locale)
club_name =
case Membership.get_settings() do
{:ok, settings} when is_binary(settings.club_name) -> settings.club_name
_ -> nil
end
socket =
socket
|> assign(:sign_out_path, session["sign_out_path"] || "/sign-out")
|> assign(:locale, locale)
|> assign(:club_name, club_name)
|> Layouts.assign_page_title(dgettext("auth", "Sign out"))
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<Layouts.public_page flash={@flash} club_name={@club_name}>
<div class="hero min-h-[40vh] bg-base-200 rounded-lg">
<div class="hero-content flex-col items-start text-left">
<div class="w-full max-w-md">
<h1 class="text-xl font-semibold leading-8 mb-4">
{dgettext("auth", "Sign out")}
</h1>
<p class="text-base-content/70 mb-4">
{dgettext("auth", "Are you sure you want to sign out?")}
</p>
<.form for={%{}} action={@sign_out_path} method="delete">
<button type="submit" class="btn btn-primary w-full">
{dgettext("auth", "Sign out")}
</button>
</.form>
</div>
</div>
</div>
</Layouts.public_page>
"""
end
end

View file

@ -112,7 +112,7 @@ defmodule MvWeb.Router do
# ASHAUTHENTICATION GENERATED AUTH ROUTES # ASHAUTHENTICATION GENERATED AUTH ROUTES
auth_routes AuthController, Mv.Accounts.User, path: "/auth" auth_routes AuthController, Mv.Accounts.User, path: "/auth"
sign_out_route AuthController sign_out_route AuthController, "/sign-out", live_view: MvWeb.SignOutLive
# Remove these if you'd like to use your own authentication views # Remove these if you'd like to use your own authentication views
sign_in_route register_path: "/register", sign_in_route register_path: "/register",

View file

@ -152,3 +152,13 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Register" msgid "Register"
msgstr "" msgstr ""
#: lib/mv_web/live/auth/sign_out_live.ex
#, elixir-autogen, elixir-format
msgid "Are you sure you want to sign out?"
msgstr ""
#: lib/mv_web/live/auth/sign_out_live.ex
#, elixir-autogen, elixir-format
msgid "Sign out"
msgstr ""

View file

@ -148,3 +148,13 @@ msgstr "Sprache auswählen"
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Register" msgid "Register"
msgstr "Registrieren" msgstr "Registrieren"
#: lib/mv_web/live/auth/sign_out_live.ex
#, elixir-autogen, elixir-format
msgid "Are you sure you want to sign out?"
msgstr "Möchtest du dich wirklich abmelden?"
#: lib/mv_web/live/auth/sign_out_live.ex
#, elixir-autogen, elixir-format
msgid "Sign out"
msgstr "Abmelden"

View file

@ -145,3 +145,13 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Register" msgid "Register"
msgstr "" msgstr ""
#: lib/mv_web/live/auth/sign_out_live.ex
#, elixir-autogen, elixir-format
msgid "Are you sure you want to sign out?"
msgstr ""
#: lib/mv_web/live/auth/sign_out_live.ex
#, elixir-autogen, elixir-format
msgid "Sign out"
msgstr ""

View file

@ -0,0 +1,19 @@
[
{
"id": "mv",
"name": "Mila dev",
"secret": { "Plain": "mv-dev-shared-secret-not-for-production-do-not-use-anywhere-else" },
"redirect_uris": ["http://localhost:4000/auth/user/oidc/callback"],
"allowed_origins": ["http://localhost:4000"],
"enabled": true,
"flows_enabled": ["authorization_code", "refresh_token"],
"access_token_alg": "RS256",
"id_token_alg": "RS256",
"auth_code_lifetime": 60,
"access_token_lifetime": 1800,
"scopes": ["openid", "profile", "email", "groups"],
"default_scopes": ["openid", "profile", "email", "groups"],
"challenges": ["S256"],
"force_mfa": false
}
]

View file

@ -62,6 +62,87 @@ defmodule MvWeb.AuthControllerTest do
assert redirected_to(conn) == ~p"/" assert redirected_to(conn) == ~p"/"
end end
describe "DELETE /sign-out with OIDC configured" do
@base_url "https://idp.example.com"
defp with_oidc_settings(fun) do
{:ok, settings} = Membership.get_settings()
prev = %{
oidc_client_id: settings.oidc_client_id,
oidc_base_url: settings.oidc_base_url,
oidc_redirect_uri: settings.oidc_redirect_uri,
oidc_client_secret: settings.oidc_client_secret
}
{:ok, _} =
Membership.update_settings(settings, %{
oidc_client_id: "test-client",
oidc_base_url: @base_url,
oidc_redirect_uri: "http://localhost:4000/auth/user/oidc/callback",
oidc_client_secret: "test-secret"
})
try do
fun.()
after
Mv.Oidc.Discovery.clear_cache()
{:ok, s} = Membership.get_settings()
Membership.update_settings(s, prev)
end
end
test "redirects to end_session_endpoint when discovery succeeds", %{
conn: authenticated_conn
} do
with_oidc_settings(fn ->
end_session_url = "https://idp.example.com/end-session"
Mv.Oidc.Discovery.put_cache(
@base_url,
{:ok, %{"end_session_endpoint" => end_session_url}}
)
conn =
authenticated_conn
|> conn_with_oidc_user()
|> delete(~p"/sign-out")
assert redirected_to(conn, 302) == end_session_url
end)
end
test "falls back to /sign-in?oidc_failed=1 when discovery fails", %{
conn: authenticated_conn
} do
with_oidc_settings(fn ->
Mv.Oidc.Discovery.put_cache(@base_url, {:error, :test_failure})
conn =
authenticated_conn
|> conn_with_oidc_user()
|> delete(~p"/sign-out")
assert redirected_to(conn) == "/sign-in?oidc_failed=1"
end)
end
test "falls back to /sign-in?oidc_failed=1 when end_session_endpoint is missing", %{
conn: authenticated_conn
} do
with_oidc_settings(fn ->
Mv.Oidc.Discovery.put_cache(@base_url, {:ok, %{"issuer" => @base_url}})
conn =
authenticated_conn
|> conn_with_oidc_user()
|> delete(~p"/sign-out")
assert redirected_to(conn) == "/sign-in?oidc_failed=1"
end)
end
end
defp csrf_token_from_sign_out_form(html) when is_binary(html) do defp csrf_token_from_sign_out_form(html) when is_binary(html) do
case Regex.run(~r/name="_csrf_token"[^>]*value="([^"]+)"/, html) do case Regex.run(~r/name="_csrf_token"[^>]*value="([^"]+)"/, html) do
[_, token] -> [_, token] ->