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_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.
|
||||||
|
|
|
||||||
18
README.md
18
README.md
|
|
@ -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.)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
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.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
|
||||||
|
|
|
||||||
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
|
# 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",
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
|
|
|
||||||
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"/"
|
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] ->
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue