Skip to content
Open
3 changes: 1 addition & 2 deletions lib/samly/helper.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ defmodule Samly.Helper do

@spec get_idp(binary) :: nil | IdpData.t()
def get_idp(idp_id) do
idps = Application.get_env(:samly, :identity_providers, %{})
Map.get(idps, idp_id)
IdpData.store().get(idp_id)
end

@spec get_metadata_uri(nil | binary, binary) :: nil | charlist
Expand Down
168 changes: 49 additions & 119 deletions lib/samly/idp_data.ex
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
defmodule Samly.IdpData do
@moduledoc false

import SweetXml
require Logger
require Samly.Esaml
alias Samly.{Esaml, Helper, IdpData, SpData}
alias Samly.{Esaml, Helper, IdpData, SpData, XmlAdapter}

@type nameid_format :: :unknown | charlist()
@type certs :: [binary()]
Expand Down Expand Up @@ -39,7 +38,7 @@ defmodule Samly.IdpData do
id: binary(),
sp_id: binary(),
base_url: nil | binary(),
metadata_file: nil | binary(),
metadata_file: nil | binary() | map(),
pre_session_create_pipeline: nil | module(),
use_redirect_for_req: boolean(),
sign_requests: boolean(),
Expand All @@ -62,29 +61,13 @@ defmodule Samly.IdpData do
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"

@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()

def store() do
Application.get_env(:samly, Samly.Provider, [])
|> Keyword.get(:idp_data_store, Samly.IdpDataStore.Config)
end

@spec load_providers([map], %{required(id()) => %SpData{}}) ::
%{required(id()) => %IdpData{}} | no_return()
def load_providers(prov_config, service_providers) do
Expand Down Expand Up @@ -121,31 +104,55 @@ defmodule Samly.IdpData do
end

@spec load_metadata(%IdpData{}, map()) :: %IdpData{}
defp load_metadata(idp_data, _opts_map) do
with {:reading, {:ok, raw_xml}} <- {:reading, File.read(idp_data.metadata_file)},
defp load_metadata(%IdpData{metadata_file: metadata_file} = idp_data, _opts_map)
when is_binary(metadata_file) do
with {:reading, {:ok, raw_xml}} <- {:reading, File.read(metadata_file)},
{:parsing, {:ok, idp_data}} <- {:parsing, from_xml(raw_xml, idp_data)} do
idp_data
else
{:reading, {:error, reason}} ->
Logger.error(
"[Samly] Failed to read metadata_file [#{inspect(idp_data.metadata_file)}]: #{
inspect(reason)
}"
"[Samly] Failed to read metadata_file [#{inspect(metadata_file)}]: #{inspect(reason)}"
)

idp_data

{:parsing, {:error, reason}} ->
Logger.error(
"[Samly] Invalid metadata_file content [#{inspect(idp_data.metadata_file)}]: #{
inspect(reason)
}"
"[Samly] Invalid metadata_file content [#{inspect(metadata_file)}]: #{inspect(reason)}"
)

idp_data
end
end

defp load_metadata(%IdpData{metadata_file: data} = idp_data, _opts_map) when is_map(data) do
# TODO defstruct on the map:
# %{
# certs: ["xyz"],
# entity_id: "http://www.abc.com/def",
# nameid_format: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddressurn:oasis:names:tc:SAML:2.0:nameid-format:transient',
# signed_requests: "false",
# slo_post_url: nil,
# slo_redirect_url: nil,
# sso_post_url: "https://url.com/sso/saml",
# sso_redirect_url: "https://url.com/sso/saml"
# }

%IdpData{
idp_data
| entity_id: data.entity_id,
signed_requests: data.signed_requests,
certs: data.certs,
fingerprints: idp_cert_fingerprints(data.certs),
sso_redirect_url: data.sso_redirect_url,
sso_post_url: data.sso_post_url,
slo_redirect_url: data.slo_redirect_url,
slo_post_url: data.slo_post_url,
nameid_format: data.nameid_format
}
end

@spec update_esaml_recs(%IdpData{}, %{required(id()) => %SpData{}}, map()) :: %IdpData{}
defp update_esaml_recs(idp_data, service_providers, opts_map) do
case Map.get(service_providers, idp_data.sp_id) do
Expand Down Expand Up @@ -253,28 +260,20 @@ defmodule Samly.IdpData do

@spec from_xml(binary, %IdpData{}) :: {:ok, %IdpData{}}
def 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)
{:ok, data} = XmlAdapter.import(metadata_xml)

{:ok,
%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)
| entity_id: data.entity_id,
signed_requests: data.signed_requests,
certs: data.certs,
fingerprints: idp_cert_fingerprints(data.certs),
sso_redirect_url: data.sso_redirect_url,
sso_post_url: data.sso_post_url,
slo_redirect_url: data.slo_redirect_url,
slo_post_url: data.slo_post_url,
nameid_format: data.nameid_format
}}
end

Expand Down Expand Up @@ -356,73 +355,4 @@ defmodule Samly.IdpData do
entity_id: sp_entity_id
)
end

@spec get_entity_id(: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(: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(:xmlElement) :: binary()
def get_req_signed(md_elem), do: get_data(md_elem, @req_signed_selector)

@spec get_signing_certs(:xmlElement) :: certs()
def get_signing_certs(md_elem), do: get_certs(md_elem, @signing_keys_selector)

@spec get_enc_certs(:xmlElement) :: certs()
def get_enc_certs(md_elem), do: get_certs(md_elem, @enc_keys_selector)

@spec get_certs(: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)
cert = xpath(e, @cert_selector |> add_ns())

cert
|> String.split()
|> Enum.map(&String.trim/1)
|> Enum.join()
end)
end

@spec get_sso_redirect_url(:xmlElement) :: url()
def get_sso_redirect_url(md_elem), do: get_url(md_elem, @sso_redirect_url_selector)

@spec get_sso_post_url(:xmlElement) :: url()
def get_sso_post_url(md_elem), do: get_url(md_elem, @sso_post_url_selector)

@spec get_slo_redirect_url(:xmlElement) :: url()
def get_slo_redirect_url(md_elem), do: get_url(md_elem, @slo_redirect_url_selector)

@spec get_slo_post_url(:xmlElement) :: url()
def get_slo_post_url(md_elem), do: get_url(md_elem, @slo_post_url_selector)

@spec get_url(: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(: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
35 changes: 35 additions & 0 deletions lib/samly/idp_data_store/config.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
defmodule Samly.IdpDataStore.Config do
@moduledoc """
Reads identity providers data from Application environment (config files).

This is the default behaviour. To change it, set the following config:

config :samly, Samly.Provider,
idp_data_store: MyApp.IdpStore

This implementation only provides `init/2` and `get/1`.any()
`delete/1` and `put/2` will return `:unsupported`.
"""

@behaviour Samly.IdpDataStore.Store

@impl true
def init(opts, service_providers) do
identity_providers =
Samly.IdpData.load_providers(opts || [], service_providers)

Application.put_env(:samly, :identity_providers, identity_providers)
end

@impl true
def get(idp_id) do
idps = Application.get_env(:samly, :identity_providers, %{})
Map.get(idps, idp_id)
end

@impl true
def put(_idp_id, _idp_data), do: :unsupported

@impl true
def delete(_idp_id), do: :unsupported
end
29 changes: 29 additions & 0 deletions lib/samly/idp_data_store/store.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
defmodule Samly.IdpDataStore.Store do
alias Samly.IdpData
alias Samly.SpData

@doc """
Called during GenServer init to initializes the store.

Takes an optional list of `identity_providers` to populate the store from,
and the already-configured map of `service_providers` data.
"""
@callback init([map], %{SpData.id() => SpData.t()}) :: :ok | {:error, any()}

@doc """
Fetches the IdpData for the given Id from the store.
"""
@callback get(binary) :: nil | IdpData.t()

@doc """
Saves the IdpData for the given Id into the store.
Could be omitted by implementation. In that case, it should return `:unsupported`
"""
@callback put(binary, IdpData.t()) :: :ok | :unsupported | {:error, any()}

@doc """
Removes the IdpData for the given Id from the store.
Could be omitted by implementation. In that case, it should return `:unsupported`
"""
@callback delete(binary) :: :ok | :unsupported | {:error, any()}
end
9 changes: 3 additions & 6 deletions lib/samly/provider.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ defmodule Samly.Provider do
require Logger

require Samly.Esaml
alias Samly.{State}
alias Samly.{State, IdpData}

@doc false
def start_link(gs_opts \\ []) do
Expand Down Expand Up @@ -58,12 +58,9 @@ defmodule Samly.Provider do
Application.put_env(:samly, :idp_id_from, idp_id_from)

service_providers = Samly.SpData.load_providers(opts[:service_providers] || [])

identity_providers =
Samly.IdpData.load_providers(opts[:identity_providers] || [], service_providers)

Application.put_env(:samly, :service_providers, service_providers)
Application.put_env(:samly, :identity_providers, identity_providers)

:ok = IdpData.store().init(opts[:identity_providers], service_providers)

{:ok, %{}}
end
Expand Down
Loading