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