Fix OIDC Loop and seed rauthy dev setup closes #510 #513
12 changed files with 312 additions and 22 deletions
|
|
@ -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.
|
||||
|
|
|
|||
18
README.md
18
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: <http://localhost:8080> — login `admin@localhost`, password from `BOOTSTRAP_ADMIN_PASSWORD_PLAIN` in `docker-compose.yml`.
|
||||
|
||||
### OIDC with other providers (Authentik, Keycloak, etc.)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
88
lib/mv/oidc/discovery.ex
Normal file
88
lib/mv/oidc/discovery.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
62
lib/mv_web/live/auth/sign_out_live.ex
Normal file
62
lib/mv_web/live/auth/sign_out_live.ex
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
19
rauthy-bootstrap/clients.json
Normal file
19
rauthy-bootstrap/clients.json
Normal 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
|
||||
}
|
||||
]
|
||||
|
|
@ -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] ->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue