Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow federation metadata os plus logout #2

Closed
wants to merge 18 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ config :samly, Samly.Provider,
| `sp_id` | _(mandatory)_ The service provider definition to be used with this Identity Provider definition |
| `base_url` | _(optional)_ If missing `Samly` will use the current URL to derive this. It is better to define this in production deployment. |
| `metadata_file` | _(mandatory)_ Path to the IdP metadata XML file obtained from the Identity Provider. |
| `entity_id` | _(optional)_ In case metadata file contains federation definition (root element is `EntitiesDescriptor`) this field is necessary. Based on that samly will extract appropriate idp element.
| `pre_session_create_pipeline` | _(optional)_ Check the customization section. |
| `use_redirect_for_req` | _(optional)_ Default is `false`. When this is `false`, `Samly` will POST to the IdP SAML endpoints. |
| `sign_requests`, `sign_metadata` | _(optional)_ Default is `true`. |
Expand Down
5 changes: 5 additions & 0 deletions lib/samly.ex
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,9 @@ defmodule Samly do
def get_attribute(%Assertion{} = assertion, name) do
Map.get(assertion.computed, name) || Map.get(assertion.attributes, name)
end

def logout(conn) do
conn
|> Conn.delete_session("samly_assertion_key")
end
end
50 changes: 39 additions & 11 deletions lib/samly/auth_handler.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ defmodule Samly.AuthHandler do
import Plug.Conn
alias Samly.{Assertion, IdpData, Helper, State, Subject}

import Samly.RouterUtil, only: [ensure_sp_uris_set: 2, send_saml_request: 5, redirect: 3]
import Samly.RouterUtil,
only: [ensure_sp_uris_set: 2, send_saml_request: 5, send_saml_request: 6, redirect: 3]

@sso_init_resp_template """
<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\"
Expand Down Expand Up @@ -41,6 +42,7 @@ defmodule Samly.AuthHandler do
import Plug.CSRFProtection, only: [get_csrf_token: 0]

target_url = conn.private[:samly_target_url] || "/"
target_url = conn.params["target_url"]

opts = [
nonce: conn.private[:samly_nonce],
Expand All @@ -54,8 +56,10 @@ defmodule Samly.AuthHandler do
|> send_resp(200, EEx.eval_string(@sso_init_resp_template, opts))
end

def send_signin_req(conn) do
%IdpData{id: idp_id} = idp = conn.private[:samly_idp]
def send_signin_req(%{host: host} = conn) do
%IdpData{id: idp_id, sso_post_url: sso_post, sso_redirect_url: sso_redirect} =
idp = conn.private[:samly_idp]

%IdpData{esaml_idp_rec: idp_rec, esaml_sp_rec: sp_rec} = idp
sp = ensure_sp_uris_set(sp_rec, conn)

Expand All @@ -76,12 +80,15 @@ defmodule Samly.AuthHandler do
|> configure_session(renew: true)
|> put_session("relay_state", relay_state)
|> put_session("idp_id", idp_id)
|> put_session("target_url", target_url)
|> put_resp_cookie("target_url", target_url, domain: strip_subdomains(host, 1))
|> send_saml_request(
idp_signin_url,
idp.use_redirect_for_req,
req_xml_frag,
relay_state
relay_state,
sp: sp,
sso_post: sso_post,
sso_redirect: sso_redirect
)
end

Expand All @@ -92,21 +99,30 @@ defmodule Samly.AuthHandler do
end

def send_signout_req(conn) do
%IdpData{id: idp_id} = idp = conn.private[:samly_idp]
%IdpData{id: idp_id, slo_post_url: slo_post, slo_redirect_url: slo_redirect} =
idp = conn.private[:samly_idp]

%IdpData{esaml_idp_rec: idp_rec, esaml_sp_rec: sp_rec} = idp
%IdpData{pre_logout_pipeline: pipeline} = idp
sp = ensure_sp_uris_set(sp_rec, conn)

target_url = conn.private[:samly_target_url] || "/"
target_url = conn.params["target_url"] |> URI.decode_www_form()

assertion_key = get_session(conn, "samly_assertion_key")

case State.get_assertion(conn, assertion_key) do
%Assertion{idp_id: ^idp_id, authn: authn, subject: subject} ->
session_index = Map.get(authn, "session_index", "")
subject_rec = Subject.to_rec(subject)

{idp_signout_url, req_xml_frag} =
Helper.gen_idp_signout_req(sp, idp_rec, subject_rec, session_index)
{:ok, {idp_signout_url, req_xml_frag}} =
Helper.gen_idp_signout_req(sp, idp_rec, subject_rec, session_index,
slo_post: slo_post,
slo_redirect: slo_redirect,
use_redirect?: idp.use_redirect_for_logout_req
)

conn = pipethrough(conn, pipeline)
conn = State.delete_assertion(conn, assertion_key)
relay_state = State.gen_id()

Expand All @@ -117,9 +133,12 @@ defmodule Samly.AuthHandler do
|> delete_session("samly_assertion_key")
|> send_saml_request(
idp_signout_url,
idp.use_redirect_for_req,
idp.use_redirect_for_logout_req,
req_xml_frag,
relay_state
relay_state,
sp: sp,
slo_post: slo_post,
slo_redirect: slo_redirect
)

_ ->
Expand All @@ -131,4 +150,13 @@ defmodule Samly.AuthHandler do
# Logger.error("#{inspect error}")
# conn |> send_resp(500, "request_failed")
end

defp pipethrough(conn, nil), do: conn
defp pipethrough(conn, pipeline), do: pipeline.call(conn, [])

defp strip_subdomains(host, n_of_subdomains) do
host
|> String.split(".", parts: n_of_subdomains + 1)
|> List.last()
end
end
42 changes: 39 additions & 3 deletions lib/samly/helper.ex
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,46 @@ defmodule Samly.Helper do
{idp_signin_url, xml_frag}
end

def gen_idp_signout_req(sp, idp_metadata, subject_rec, session_index) do
def gen_idp_signout_req(sp, idp_metadata, subject_rec, session_index, opts) do
idp_signout_url = Esaml.esaml_idp_metadata(idp_metadata, :logout_location)
xml_frag = :esaml_sp.generate_logout_request(idp_signout_url, session_index, subject_rec, sp)
{idp_signout_url, xml_frag}

case get_binding_type(opts) do
{:ok, binding_type} ->
xml_frag =
:esaml_sp.generate_logout_request(
idp_signout_url,
session_index,
subject_rec,
sp,
binding_type: binding_type
)

{:ok, {idp_signout_url, xml_frag}}

error ->
error
end
end

defp get_binding_type(opts) do
# TODO refactor
use_redirect? = Keyword.get(opts, :use_redirect?)
redirect = Keyword.get(opts, :slo_redirect)
post = Keyword.get(opts, :slo_post)

if use_redirect? do
if redirect do
{:ok, :redirect}
else
{:error, :no_binding}
end
else
if post do
{:ok, :post}
else
{:error, :no_binding}
end
end
end

def gen_idp_signout_resp(sp, idp_metadata, signout_status) do
Expand Down
115 changes: 83 additions & 32 deletions lib/samly/idp_data.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ defmodule Samly.IdpData do
base_url: nil,
metadata_file: nil,
pre_session_create_pipeline: nil,
pre_logout_pipeline: nil,
use_redirect_for_req: false,
sign_requests: true,
sign_metadata: true,
Expand All @@ -41,6 +42,7 @@ defmodule Samly.IdpData do
base_url: nil | binary(),
metadata_file: nil | binary(),
pre_session_create_pipeline: nil | module(),
pre_logout_pipeline: nil | module(),
use_redirect_for_req: boolean(),
sign_requests: boolean(),
sign_metadata: boolean(),
Expand Down Expand Up @@ -73,16 +75,22 @@ defmodule Samly.IdpData do
@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

# These functions work on EntityDescriptor element
@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
# TODO How to deal with multiple nameid formats?
@nameid_format_selector ~x"/#{@entdesc}/#{@idpdesc}/#{@nameid}/text()[1]"s
@signing_keys_in_idp_selector ~x"./#{@idpdesc}/#{@keydesc}[@use != 'encryption']"l
@cert_selector ~x"./ds:KeyInfo/ds:X509Data/ds:X509Certificate/text()"s

defp entity_by_id_selector(id), do: ~x"/#{@entsdesc}/#{@entdesc}[@entityID = '#{id}'][1]"

@type id :: binary()

@spec load_providers([map], %{required(id()) => %SpData{}}) ::
Expand Down Expand Up @@ -110,9 +118,10 @@ defmodule Samly.IdpData do
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)}
|> set_metadata_file(opts_map)
|> set_pipeline(opts_map)
|> set_pipelines(opts_map)
|> set_allowed_target_urls(opts_map)
|> set_boolean_attr(opts_map, :use_redirect_for_req)
|> set_boolean_attr(opts_map, :use_redirect_for_logout_req)
|> set_boolean_attr(opts_map, :sign_requests)
|> set_boolean_attr(opts_map, :sign_metadata)
|> set_boolean_attr(opts_map, :signed_assertion_in_resp)
Expand All @@ -121,9 +130,9 @@ defmodule Samly.IdpData do
end

@spec load_metadata(%IdpData{}, map()) :: %IdpData{}
defp load_metadata(idp_data, _opts_map) do
defp load_metadata(idp_data, opts_map) do
with {:reading, {:ok, raw_xml}} <- {:reading, File.read(idp_data.metadata_file)},
{:parsing, {:ok, idp_data}} <- {:parsing, from_xml(raw_xml, idp_data)} do
{:parsing, {:ok, idp_data}} <- {:parsing, from_xml(raw_xml, idp_data, opts_map)} do
idp_data
else
{:reading, {:error, reason}} ->
Expand Down Expand Up @@ -187,10 +196,16 @@ defmodule Samly.IdpData do
%IdpData{idp_data | metadata_file: Map.get(opts_map, :metadata_file, @default_metadata_file)}
end

@spec set_pipeline(%IdpData{}, map()) :: %IdpData{}
defp set_pipeline(%IdpData{} = idp_data, %{} = opts_map) do
@spec set_pipelines(%IdpData{}, map()) :: %IdpData{}
defp set_pipelines(%IdpData{} = idp_data, %{} = opts_map) do
pipeline = Map.get(opts_map, :pre_session_create_pipeline)
%IdpData{idp_data | pre_session_create_pipeline: pipeline}
logout_pipeline = Map.get(opts_map, :pre_logout_pipeline)

%IdpData{
idp_data
| pre_session_create_pipeline: pipeline,
pre_logout_pipeline: logout_pipeline
}
end

defp set_allowed_target_urls(%IdpData{} = idp_data, %{} = opts_map) do
Expand Down Expand Up @@ -251,8 +266,8 @@ defmodule Samly.IdpData do
if is_boolean(v), do: Map.put(idp_data, attr_name, v), else: idp_data
end

@spec from_xml(binary, %IdpData{}) :: {:ok, %IdpData{}}
def from_xml(metadata_xml, idp_data) when is_binary(metadata_xml) do
@spec from_xml(binary, %IdpData{}, %{}) :: {:ok, %IdpData{}}
def from_xml(metadata_xml, idp_data, opts) when is_binary(metadata_xml) do
xml_opts = [
space: :normalize,
namespace_conformant: true,
Expand All @@ -261,23 +276,49 @@ defmodule Samly.IdpData do
]

md_xml = SweetXml.parse(metadata_xml, xml_opts)
signing_certs = get_signing_certs(md_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)
}}

entityID =
case federation_metadata?(opts) do
false -> get_entity_id(md_xml)
true -> opts[:entity_id]
end

entity_md_xml = get_entity_descriptor(md_xml, entityID)

case entity_md_xml do
nil ->
Logger.warn("[Samly] Entity #{inspect(entityID)} not found")
{:ok, idp_data}

{:error, :entity_not_found} = err ->
Logger.warn("[Samly] Entity not found due to configuration error")
{:ok, idp_data}

{:error, reason} ->
Logger.warn("[Samly] Parsing error due to: #{inspect(reason)}")
{:ok, idp_data}

_ ->
signing_certs = get_signing_certs_in_idp(entity_md_xml)

{:ok,
%IdpData{
idp_data
| entity_id: entityID,
signed_requests: get_req_signed(md_xml),
certs: signing_certs,
fingerprints: idp_cert_fingerprints(signing_certs),
sso_redirect_url: get_sso_redirect_url(entity_md_xml),
sso_post_url: get_sso_post_url(entity_md_xml),
slo_redirect_url: get_slo_redirect_url(entity_md_xml),
slo_post_url: get_slo_post_url(entity_md_xml),
nameid_format: get_nameid_format(entity_md_xml)
}}
end
end

defp federation_metadata?(opts), do: opts[:entity_id] != nil

# @spec to_esaml_idp_metadata(IdpData.t(), map()) :: :esaml_idp_metadata
defp to_esaml_idp_metadata(%IdpData{} = idp_data, %{} = idp_config) do
{sso_url, slo_url} = get_sso_slo_urls(idp_data, idp_config)
Expand Down Expand Up @@ -373,11 +414,10 @@ defmodule Samly.IdpData do
@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_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)
def get_signing_certs_in_idp(md_elem), do: get_certs(md_elem, @signing_keys_in_idp_selector)

@spec get_certs(:xmlElement, %SweetXpath{}) :: certs()
defp get_certs(md_elem, key_selector) do
Expand Down Expand Up @@ -425,4 +465,15 @@ defmodule Samly.IdpData do
|> SweetXml.add_namespace("md", "urn:oasis:names:tc:SAML:2.0:metadata")
|> SweetXml.add_namespace("ds", "http://www.w3.org/2000/09/xmldsig#")
end

@spec get_entity_descriptor(:xmlElement, entityID :: binary()) :: :xmlElement | nil
defp get_entity_descriptor(md_xml, entityID) do
selector = entity_by_id_selector(entityID) |> add_ns()

try do
SweetXml.xpath(md_xml, selector)
rescue
_ -> {:error, :entity_not_found}
end
end
end
Loading