From 22955bdd9e75b0be4dbb124497819f2d56015c0f Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 1 Jun 2026 19:06:14 +0200 Subject: [PATCH 1/3] feat(rauthy): auto-seed mv OIDC client via bootstrap dir --- .env.example | 2 +- README.md | 18 +++--------------- docker-compose.yml | 4 ++++ rauthy-bootstrap/clients.json | 20 ++++++++++++++++++++ 4 files changed, 28 insertions(+), 16 deletions(-) create mode 100644 rauthy-bootstrap/clients.json diff --git a/.env.example b/.env.example index d63e019..bc0ef7a 100644 --- a/.env.example +++ b/.env.example @@ -24,7 +24,7 @@ ASSOCIATION_NAME="Sportsclub XYZ" # OIDC_CLIENT_ID=mv # OIDC_BASE_URL=http://localhost:8080/auth/v1 # 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) # If OIDC_ADMIN_GROUP_NAME is set, users in that group get Admin role on registration/sign-in. diff --git a/README.md b/README.md index 9fc2f83..8b26327 100644 --- a/README.md +++ b/README.md @@ -124,8 +124,8 @@ mix archive.install hex phx_new 1. Copy env file: ```bash 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): ```bash @@ -139,21 +139,9 @@ mix archive.install hex phx_new ## 🔐 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` -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! +Rauthy admin UI: — login `admin@localhost`, password from `BOOTSTRAP_ADMIN_PASSWORD_PLAIN` in `docker-compose.yml`. ### OIDC with other providers (Authentik, Keycloak, etc.) diff --git a/docker-compose.yml b/docker-compose.yml index 512626b..01a0bd2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,9 @@ services: - BOOTSTRAP_ADMIN_PASSWORD_PLAIN=RauthyTest12345 # Disable strict IP validation to allow access from multiple Docker networks - 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: - "8080:8080" depends_on: @@ -46,6 +49,7 @@ services: - local volumes: - rauthy-data:/app/data + - ./rauthy-bootstrap:/app/bootstrap:ro volumes: postgres-data: diff --git a/rauthy-bootstrap/clients.json b/rauthy-bootstrap/clients.json new file mode 100644 index 0000000..e0f608a --- /dev/null +++ b/rauthy-bootstrap/clients.json @@ -0,0 +1,20 @@ +[ + { + "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"], + "post_logout_redirect_uris": ["http://localhost:4000/"], + "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 + } +] From ba66bc15db665c4b467634ba60f6a21ab397e594 Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 1 Jun 2026 19:59:52 +0200 Subject: [PATCH 2/3] fix(auth): trigger RP-initiated logout at OIDC provider --- lib/mv/oidc/discovery.ex | 88 +++++++++++++++++++ lib/mv_web/controllers/auth_controller.ex | 28 ++++-- rauthy-bootstrap/clients.json | 1 - .../controllers/auth_controller_test.exs | 81 +++++++++++++++++ 4 files changed, 192 insertions(+), 6 deletions(-) create mode 100644 lib/mv/oidc/discovery.ex diff --git a/lib/mv/oidc/discovery.ex b/lib/mv/oidc/discovery.ex new file mode 100644 index 0000000..a3a373a --- /dev/null +++ b/lib/mv/oidc/discovery.ex @@ -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 diff --git a/lib/mv_web/controllers/auth_controller.ex b/lib/mv_web/controllers/auth_controller.ex index adde4e8..120c245 100644 --- a/lib/mv_web/controllers/auth_controller.ex +++ b/lib/mv_web/controllers/auth_controller.ex @@ -16,6 +16,7 @@ defmodule MvWeb.AuthController do alias Mv.Accounts.User.Errors.PasswordVerificationRequired alias Mv.Config + alias Mv.Oidc.Discovery def success(conn, {:password, :sign_in} = _activity, user, token) do if Config.oidc_only?() do @@ -337,11 +338,28 @@ defmodule MvWeb.AuthController do defp redact_url(_), do: "[redacted]" 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 - |> clear_session(:mv) - |> put_flash(:success, gettext("You are now signed out")) - |> redirect(to: return_to) + case oidc_end_session_url() do + {:ok, url} -> + redirect(conn, external: url) + + :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 diff --git a/rauthy-bootstrap/clients.json b/rauthy-bootstrap/clients.json index e0f608a..6869410 100644 --- a/rauthy-bootstrap/clients.json +++ b/rauthy-bootstrap/clients.json @@ -4,7 +4,6 @@ "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"], - "post_logout_redirect_uris": ["http://localhost:4000/"], "allowed_origins": ["http://localhost:4000"], "enabled": true, "flows_enabled": ["authorization_code", "refresh_token"], diff --git a/test/mv_web/controllers/auth_controller_test.exs b/test/mv_web/controllers/auth_controller_test.exs index 9282144..53c41c6 100644 --- a/test/mv_web/controllers/auth_controller_test.exs +++ b/test/mv_web/controllers/auth_controller_test.exs @@ -62,6 +62,87 @@ defmodule MvWeb.AuthControllerTest do assert redirected_to(conn) == ~p"/" 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 case Regex.run(~r/name="_csrf_token"[^>]*value="([^"]+)"/, html) do [_, token] -> From a27425b5fbc8fb362a4364b29c5bc109dc3e9bfa Mon Sep 17 00:00:00 2001 From: Moritz Date: Mon, 1 Jun 2026 20:06:32 +0200 Subject: [PATCH 3/3] fix(auth): replace sign-out page with accessible custom LiveView --- lib/mv_web/live/auth/sign_out_live.ex | 62 +++++++++++++++++++++++++++ lib/mv_web/router.ex | 2 +- priv/gettext/auth.pot | 10 +++++ priv/gettext/de/LC_MESSAGES/auth.po | 10 +++++ priv/gettext/en/LC_MESSAGES/auth.po | 10 +++++ 5 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 lib/mv_web/live/auth/sign_out_live.ex diff --git a/lib/mv_web/live/auth/sign_out_live.ex b/lib/mv_web/live/auth/sign_out_live.ex new file mode 100644 index 0000000..2a0e0df --- /dev/null +++ b/lib/mv_web/live/auth/sign_out_live.ex @@ -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""" + +
+
+
+

+ {dgettext("auth", "Sign out")} +

+

+ {dgettext("auth", "Are you sure you want to sign out?")} +

+ <.form for={%{}} action={@sign_out_path} method="delete"> + + +
+
+
+
+ """ + end +end diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex index 591dead..f42eb29 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -112,7 +112,7 @@ defmodule MvWeb.Router do # ASHAUTHENTICATION GENERATED AUTH ROUTES 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 sign_in_route register_path: "/register", diff --git a/priv/gettext/auth.pot b/priv/gettext/auth.pot index cd46c56..4fbbeda 100644 --- a/priv/gettext/auth.pot +++ b/priv/gettext/auth.pot @@ -152,3 +152,13 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Register" 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 "" diff --git a/priv/gettext/de/LC_MESSAGES/auth.po b/priv/gettext/de/LC_MESSAGES/auth.po index 07583be..602a106 100644 --- a/priv/gettext/de/LC_MESSAGES/auth.po +++ b/priv/gettext/de/LC_MESSAGES/auth.po @@ -148,3 +148,13 @@ msgstr "Sprache auswählen" #, elixir-autogen, elixir-format msgid "Register" 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" diff --git a/priv/gettext/en/LC_MESSAGES/auth.po b/priv/gettext/en/LC_MESSAGES/auth.po index 564e640..8f78349 100644 --- a/priv/gettext/en/LC_MESSAGES/auth.po +++ b/priv/gettext/en/LC_MESSAGES/auth.po @@ -145,3 +145,13 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Register" 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 ""