defmodule ExSaml.IdpData do @moduledoc false import SweetXml require Logger alias ExSaml.{Core, Helper, IdpData, SpData} @type nameid_format :: :unknown | charlist() @type certs :: [binary()] @type url :: nil | binary() defstruct id: "", sp_id: "", base_url: nil, metadata: nil, metadata_file: nil, use_redirect_for_req: false, remove_saml_encoding: false, sign_requests: true, sign_metadata: true, signed_assertion_in_resp: true, signed_envelopes_in_resp: false, allow_idp_initiated_flow: true, use_redirect_for_slo: false, allowed_target_urls: [], entity_id: "", signed_requests: "", certs: [], sso_redirect_url: nil, sso_post_url: nil, slo_redirect_url: nil, slo_post_url: nil, nameid_format: :unknown, fingerprints: [], idp_metadata: %Core.IdpMetadata{}, sp_config: %Core.SpConfig{}, valid?: false @type t :: %__MODULE__{ id: binary(), sp_id: binary(), base_url: nil | binary(), metadata: nil | binary(), metadata_file: nil | binary(), use_redirect_for_req: boolean(), use_redirect_for_slo: boolean(), sign_requests: boolean(), sign_metadata: boolean(), signed_assertion_in_resp: boolean(), signed_envelopes_in_resp: boolean(), allow_idp_initiated_flow: boolean(), allowed_target_urls: nil | [binary()], entity_id: binary(), signed_requests: binary(), certs: certs(), sso_redirect_url: url(), sso_post_url: url(), slo_redirect_url: url(), slo_post_url: url(), nameid_format: nameid_format(), fingerprints: [binary()], idp_metadata: Core.IdpMetadata.t(), sp_config: Core.SpConfig.t(), valid?: boolean() } @entdesc "md:EntityDescriptor" @idpdesc "md:IDPSSODescriptor" @signedreq "WantAuthnRequestsSigned" @nameid "md:NameIDFormat" @keydesc "md:KeyDescriptor" @ssos "md:SingleSignOnService" @slos "md:SingleLogoutService" @redirect "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" @post "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" @deflate_saml_encoding "urn:oasis:names:tc:SAML:2.0:bindings:URL-Encoding:DEFLATE" @entity_id_selector ~x"//#{@entdesc}/@entityID"sl @nameid_format_selector ~x"//#{@entdesc}/#{@idpdesc}/#{@nameid}/text()"s @req_signed_selector ~x"//#{@entdesc}/#{@idpdesc}/@#{@signedreq}"s @sso_redirect_url_selector ~x"//#{@entdesc}/#{@idpdesc}/#{@ssos}[@Binding = '#{@redirect}']/@Location"s @sso_post_url_selector ~x"//#{@entdesc}/#{@idpdesc}/#{@ssos}[@Binding = '#{@post}']/@Location"s @slo_redirect_url_selector ~x"//#{@entdesc}/#{@idpdesc}/#{@slos}[@Binding = '#{@redirect}']/@Location"s @slo_post_url_selector ~x"//#{@entdesc}/#{@idpdesc}/#{@slos}[@Binding = '#{@post}']/@Location"s @signing_keys_selector ~x"//#{@entdesc}/#{@idpdesc}/#{@keydesc}[@use != 'encryption']"l @enc_keys_selector ~x"//#{@entdesc}/#{@idpdesc}/#{@keydesc}[@use = 'encryption']"l @cert_selector ~x"./ds:KeyInfo/ds:X509Data/ds:X509Certificate/text()"s @type id :: binary() @spec load_providers([map], %{required(id()) => %SpData{}}) :: %{required(id()) => %IdpData{}} | no_return() def load_providers(prov_config, service_providers) do prov_config |> Enum.map(fn idp_config -> load_provider(idp_config, service_providers) end) |> Enum.filter(fn idp_data -> idp_data.valid? end) |> Enum.map(fn idp_data -> {idp_data.id, idp_data} end) |> Enum.into(%{}) end @spec load_provider(map(), %{required(id()) => %SpData{}}) :: %IdpData{} | no_return def load_provider(idp_config, service_providers) do %IdpData{} |> save_idp_config(idp_config) |> load_metadata() |> override_nameid_format(idp_config) |> update_core_configs(service_providers, idp_config) |> verify_slo_url() end def oasis_redirect_flow_uri, do: @deflate_saml_encoding @spec save_idp_config(%IdpData{}, map()) :: %IdpData{} defp save_idp_config(idp_data, %{id: id} = _opts_map) when id == "" do Logger.error("[ExSaml] Invalid IdP Config: id must not be empty") idp_data end defp save_idp_config(idp_data, %{id: id, sp_id: sp_id} = opts_map) when is_binary(id) and is_binary(sp_id) do %IdpData{ idp_data | id: id, sp_id: sp_id, base_url: Map.get(opts_map, :base_url), metadata: Map.get(opts_map, :metadata) } |> set_metadata(opts_map) |> set_allowed_target_urls(opts_map) |> set_boolean_attr(opts_map, :use_redirect_for_req) |> set_boolean_attr(opts_map, :sign_requests) |> set_boolean_attr(opts_map, :sign_metadata) |> set_boolean_attr(opts_map, :signed_assertion_in_resp) |> set_boolean_attr(opts_map, :signed_envelopes_in_resp) |> set_boolean_attr(opts_map, :allow_idp_initiated_flow) |> set_boolean_attr(opts_map, :remove_saml_encoding) end @spec load_metadata(%IdpData{}) :: %IdpData{} defp load_metadata(idp_data = %IdpData{metadata: metadata}) when not is_nil(metadata), do: from_xml(metadata, idp_data) # metadata_file comes from application config set by the developer, not from user input. # sobelow_skip ["Traversal.FileModule"] defp load_metadata(idp_data = %IdpData{metadata_file: metadata_file}) when not is_nil(metadata_file) do case File.read(idp_data.metadata_file) do {:ok, metadata} -> load_metadata(%{idp_data | metadata: metadata}) {:error, reason} -> Logger.error( "[ExSaml] Failed to read metadata_file [#{inspect(idp_data.metadata_file)}]: #{inspect(reason)}" ) idp_data end end defp load_metadata(idp_data) do Logger.error( "[ExSaml] Either `metadata` or `metadata_file` must be specified in the IdP configuration" ) idp_data end @spec update_core_configs(%IdpData{}, %{required(id()) => %SpData{}}, map()) :: %IdpData{} defp update_core_configs(idp_data, service_providers, opts_map) do case Map.get(service_providers, idp_data.sp_id) do %SpData{} = sp -> idp_data = %IdpData{idp_data | idp_metadata: build_idp_metadata(idp_data, opts_map)} idp_data = %IdpData{idp_data | sp_config: build_sp_config(sp, idp_data)} %IdpData{idp_data | valid?: cert_config_ok?(idp_data, sp)} _ -> Logger.error("[ExSaml] Unknown/invalid sp_id: #{idp_data.sp_id}") idp_data end end @spec cert_config_ok?(%IdpData{}, %SpData{}) :: boolean defp cert_config_ok?(%IdpData{} = idp_data, %SpData{} = sp_data) do if (idp_data.sign_metadata || idp_data.sign_requests) && (sp_data.cert == :undefined || sp_data.key == :undefined) do Logger.error("[ExSaml] SP cert or key missing - Skipping identity provider: #{idp_data.id}") false else true end end @spec verify_slo_url(%IdpData{}) :: %IdpData{} defp verify_slo_url(%IdpData{} = idp_data) do if idp_data.valid? && idp_data.slo_redirect_url == nil && idp_data.slo_post_url == nil do Logger.warning("[ExSaml] SLO Endpoint missing in [#{inspect(idp_data.metadata_file)}]") end idp_data end @default_metadata_file "idp_metadata.xml" @spec set_metadata(%IdpData{}, map()) :: %IdpData{} defp set_metadata(%IdpData{} = idp_data, %{} = opts_map) do %IdpData{ idp_data | metadata_file: Map.get(opts_map, :metadata_file, @default_metadata_file), metadata: opts_map[:metadata] } end defp set_allowed_target_urls(%IdpData{} = idp_data, %{} = opts_map) do target_urls = case Map.get(opts_map, :allowed_target_urls, nil) do nil -> nil urls when is_list(urls) -> Enum.filter(urls, &is_binary/1) end %IdpData{idp_data | allowed_target_urls: target_urls} end @spec override_nameid_format(%IdpData{}, map()) :: %IdpData{} defp override_nameid_format(%IdpData{} = idp_data, idp_config) do nameid_format = case Map.get(idp_config, :nameid_format, "") do "" -> idp_data.nameid_format format when is_binary(format) -> to_charlist(format) atom_format when is_atom(atom_format) -> resolve_nameid_format(atom_format, idp_data) end %IdpData{idp_data | nameid_format: nameid_format} end defp resolve_nameid_format(:email, _), do: ~c"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" defp resolve_nameid_format(:x509, _), do: ~c"urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName" defp resolve_nameid_format(:windows, _), do: ~c"urn:oasis:names:tc:SAML:1.1:nameid-format:WindowsDomainQualifiedName" defp resolve_nameid_format(:krb, _), do: ~c"urn:oasis:names:tc:SAML:2.0:nameid-format:kerberos" defp resolve_nameid_format(:persistent, _), do: ~c"urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" defp resolve_nameid_format(:transient, _), do: ~c"urn:oasis:names:tc:SAML:2.0:nameid-format:transient" defp resolve_nameid_format(invalid, idp_data) do Logger.error( "[ExSaml] invalid nameid_format [#{inspect(idp_data.metadata_file)}]: #{inspect(invalid)}" ) idp_data.nameid_format end @spec set_boolean_attr(%IdpData{}, map(), atom()) :: %IdpData{} defp set_boolean_attr(%IdpData{} = idp_data, %{provider_type: :adfs}, attr_name) when attr_name not in ~w(allow_idp_initiated_flow use_redirect_for_slo)a do Map.put(idp_data, attr_name, false) end defp set_boolean_attr(%IdpData{} = idp_data, %{provider_type: provider_type}, attr_name) when provider_type in ~w(ping_federate ping_one)a and attr_name in ~w(signed_assertion_in_resp signed_envelopes_in_resp)a do Map.put(idp_data, attr_name, false) end defp set_boolean_attr( %IdpData{} = idp_data, %{provider_type: provider_type}, :signed_envelopes_in_resp ) when provider_type in ~w(unset ibm_security_verify)a do Map.put(idp_data, :signed_envelopes_in_resp, true) end defp set_boolean_attr(%IdpData{} = idp_data, %{} = opts_map, attr_name) when is_atom(attr_name) do v = Map.get(opts_map, attr_name) if is_boolean(v), do: Map.put(idp_data, attr_name, v), else: idp_data end @spec from_xml(binary, %IdpData{}) :: %IdpData{} defp from_xml(metadata_xml, idp_data) when is_binary(metadata_xml) do xml_opts = [ space: :normalize, namespace_conformant: true, comments: false, default_attrs: true ] md_xml = SweetXml.parse(metadata_xml, xml_opts) signing_certs = get_signing_certs(md_xml) %IdpData{ idp_data | entity_id: get_entity_id(md_xml), signed_requests: get_req_signed(md_xml), certs: signing_certs, fingerprints: idp_cert_fingerprints(signing_certs), sso_redirect_url: get_sso_redirect_url(md_xml), sso_post_url: get_sso_post_url(md_xml), slo_redirect_url: get_slo_redirect_url(md_xml), slo_post_url: get_slo_post_url(md_xml), nameid_format: get_nameid_format(md_xml) } end defp build_idp_metadata(%IdpData{} = idp_data, %{} = idp_config) do {sso_url, slo_url} = get_sso_slo_urls(idp_data, idp_config) sso_url = if is_binary(sso_url), do: String.to_charlist(sso_url), else: [] slo_url = if is_binary(slo_url), do: String.to_charlist(slo_url), else: nil %Core.IdpMetadata{ entity_id: String.to_charlist(idp_data.entity_id), login_location: sso_url, logout_location: slo_url, name_format: idp_data.nameid_format } end defp get_sso_slo_urls(%IdpData{} = idp_data, %{use_redirect_for_req: true} = opts) do {idp_data.sso_redirect_url, slo_url(idp_data, opts)} end defp get_sso_slo_urls(%IdpData{} = idp_data, %{use_redirect_for_req: false} = opts) do {idp_data.sso_post_url, slo_url(idp_data, opts)} end defp get_sso_slo_urls(%IdpData{} = idp_data, _opts_map) do { idp_data.sso_post_url || idp_data.sso_redirect_url, idp_data.slo_post_url || idp_data.slo_redirect_url } end defp slo_url(idp_data, %{use_redirect_for_slo: true}) do Logger.info("slo url when use_redirect_for_slo checked for '#{idp_data.id}'") idp_data.slo_redirect_url end defp slo_url(idp_data, %{use_redirect_for_req: true}) do idp_data.slo_redirect_url end defp slo_url(idp_data, %{use_redirect_for_req: false}) do idp_data.slo_post_url end @spec idp_cert_fingerprints(certs()) :: [binary()] defp idp_cert_fingerprints(certs) when is_list(certs) do certs |> Enum.map(&Base.decode64!/1) |> Enum.map(&cert_fingerprint/1) |> Enum.map(&String.to_charlist/1) |> Core.Util.convert_fingerprints() end defp cert_fingerprint(dercert) do "sha256:" <> (:sha256 |> :crypto.hash(dercert) |> Base.encode64()) end defp build_sp_config(%SpData{} = sp_data, %IdpData{} = idp_data) do idp_id_from = Application.get_env(:ex_saml, :idp_id_from) path_segment_idp_id = if idp_id_from == :subdomain, do: nil, else: idp_data.id sp_entity_id = case sp_data.entity_id do "" -> nil id -> String.to_charlist(id) end %Core.SpConfig{ org: %Core.Org{ name: String.to_charlist(sp_data.org_name), displayname: String.to_charlist(sp_data.org_displayname), url: String.to_charlist(sp_data.org_url) }, tech: %Core.Contact{ name: String.to_charlist(sp_data.contact_name), email: String.to_charlist(sp_data.contact_email) }, key: sp_data.key, certificate: sp_data.cert, sp_sign_requests: idp_data.sign_requests, sp_sign_metadata: idp_data.sign_metadata, idp_signs_envelopes: idp_data.signed_envelopes_in_resp, idp_signs_assertions: idp_data.signed_assertion_in_resp, trusted_fingerprints: idp_data.fingerprints, metadata_uri: Helper.get_metadata_uri(idp_data.base_url, path_segment_idp_id), consume_uri: Helper.get_consume_uri(idp_data.base_url, path_segment_idp_id), logout_uri: Helper.get_logout_uri(idp_data.base_url, path_segment_idp_id), entity_id: sp_entity_id } end @spec get_entity_id(SweetXml.xmlElement()) :: binary() def get_entity_id(md_elem) do md_elem |> xpath(@entity_id_selector |> add_ns()) |> hd() |> String.trim() end @spec get_nameid_format(SweetXml.xmlElement()) :: nameid_format() def get_nameid_format(md_elem) do case get_data(md_elem, @nameid_format_selector) do "" -> :unknown nameid_format -> to_charlist(nameid_format) end end @spec get_req_signed(SweetXml.xmlElement()) :: binary() def get_req_signed(md_elem), do: get_data(md_elem, @req_signed_selector) @spec get_signing_certs(SweetXml.xmlElement()) :: certs() def get_signing_certs(md_elem), do: get_certs(md_elem, @signing_keys_selector) @spec get_enc_certs(SweetXml.xmlElement()) :: certs() def get_enc_certs(md_elem), do: get_certs(md_elem, @enc_keys_selector) @spec get_certs(SweetXml.xmlElement(), %SweetXpath{}) :: certs() defp get_certs(md_elem, key_selector) do md_elem |> xpath(key_selector |> add_ns()) |> Enum.map(fn e -> # Extract base64 encoded cert from XML (strip away any whitespace) e |> xpath(@cert_selector |> add_ns()) |> String.split() |> Enum.map_join(&String.trim/1) end) end @spec get_sso_redirect_url(SweetXml.xmlElement()) :: url() def get_sso_redirect_url(md_elem), do: get_url(md_elem, @sso_redirect_url_selector) @spec get_sso_post_url(SweetXml.xmlElement()) :: url() def get_sso_post_url(md_elem), do: get_url(md_elem, @sso_post_url_selector) @spec get_slo_redirect_url(SweetXml.xmlElement()) :: url() def get_slo_redirect_url(md_elem), do: get_url(md_elem, @slo_redirect_url_selector) @spec get_slo_post_url(SweetXml.xmlElement()) :: url() def get_slo_post_url(md_elem), do: get_url(md_elem, @slo_post_url_selector) @spec get_url(SweetXml.xmlElement(), %SweetXpath{}) :: url() defp get_url(md_elem, selector) do case get_data(md_elem, selector) do "" -> nil url -> url end end @spec get_data(SweetXml.xmlElement(), %SweetXpath{}) :: binary() def get_data(md_elem, selector) do md_elem |> xpath(selector |> add_ns()) |> String.trim() end @spec add_ns(%SweetXpath{}) :: %SweetXpath{} defp add_ns(xpath) do xpath |> SweetXml.add_namespace("md", "urn:oasis:names:tc:SAML:2.0:metadata") |> SweetXml.add_namespace("ds", "http://www.w3.org/2000/09/xmldsig#") end end