Skip to content

Commit

Permalink
Merge pull request #13 from idyll/cached-saml-session-expiry
Browse files Browse the repository at this point in the history
Get attestation from ETS or Session now checks for expiry.
  • Loading branch information
k-cross committed Jan 29, 2024
2 parents 9caa51f + 812b5c3 commit 7637ebe
Show file tree
Hide file tree
Showing 8 changed files with 136 additions and 57 deletions.
1 change: 1 addition & 0 deletions lib/samly/auth_handler.ex
Expand Up @@ -73,6 +73,7 @@ defmodule Samly.AuthHandler do
Helper.gen_idp_signin_req(sp, idp_rec, Map.get(idp, :nameid_format))

conn
|> State.delete_assertion(assertion_key)
|> configure_session(renew: true)
|> put_session("relay_state", relay_state)
|> put_session("idp_id", idp_id)
Expand Down
14 changes: 7 additions & 7 deletions lib/samly/helper.ex
Expand Up @@ -79,14 +79,14 @@ defmodule Samly.Helper do

def decode_idp_signout_resp(sp, saml_encoding, saml_response) do
resp_ns = [
{'samlp', 'urn:oasis:names:tc:SAML:2.0:protocol'},
{'saml', 'urn:oasis:names:tc:SAML:2.0:assertion'},
{'ds', 'http://www.w3.org/2000/09/xmldsig#'}
{~c"samlp", ~c"urn:oasis:names:tc:SAML:2.0:protocol"},
{~c"saml", ~c"urn:oasis:names:tc:SAML:2.0:assertion"},
{~c"ds", ~c"http://www.w3.org/2000/09/xmldsig#"}
]

with {:ok, xml_frag} <- decode_saml_payload(saml_encoding, saml_response),
nodes when is_list(nodes) and length(nodes) == 1 <-
:xmerl_xpath.string('/samlp:LogoutResponse', xml_frag, [{:namespace, resp_ns}]) do
:xmerl_xpath.string(~c"/samlp:LogoutResponse", xml_frag, [{:namespace, resp_ns}]) do
:esaml_sp.validate_logout_response(xml_frag, sp)
else
_ -> {:error, :invalid_request}
Expand All @@ -95,13 +95,13 @@ defmodule Samly.Helper do

def decode_idp_signout_req(sp, saml_encoding, saml_request) do
req_ns = [
{'samlp', 'urn:oasis:names:tc:SAML:2.0:protocol'},
{'saml', 'urn:oasis:names:tc:SAML:2.0:assertion'}
{~c"samlp", ~c"urn:oasis:names:tc:SAML:2.0:protocol"},
{~c"saml", ~c"urn:oasis:names:tc:SAML:2.0:assertion"}
]

with {:ok, xml_frag} <- decode_saml_payload(saml_encoding, saml_request),
nodes when is_list(nodes) and length(nodes) == 1 <-
:xmerl_xpath.string('/samlp:LogoutRequest', xml_frag, [{:namespace, req_ns}]) do
:xmerl_xpath.string(~c"/samlp:LogoutRequest", xml_frag, [{:namespace, req_ns}]) do
:esaml_sp.validate_logout_request(xml_frag, sp)
else
_ -> {:error, :invalid_request}
Expand Down
14 changes: 7 additions & 7 deletions lib/samly/idp_data.ex
Expand Up @@ -177,7 +177,7 @@ defmodule Samly.IdpData do
@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.warn("[Samly] SLO Endpoint missing in [#{inspect(idp_data.metadata_file)}]")
Logger.warning("[Samly] SLO Endpoint missing in [#{inspect(idp_data.metadata_file)}]")
end

idp_data
Expand Down Expand Up @@ -221,22 +221,22 @@ defmodule Samly.IdpData do
to_charlist(format)

:email ->
'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'
~c"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"

:x509 ->
'urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName'
~c"urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName"

:windows ->
'urn:oasis:names:tc:SAML:1.1:nameid-format:WindowsDomainQualifiedName'
~c"urn:oasis:names:tc:SAML:1.1:nameid-format:WindowsDomainQualifiedName"

:krb ->
'urn:oasis:names:tc:SAML:2.0:nameid-format:kerberos'
~c"urn:oasis:names:tc:SAML:2.0:nameid-format:kerberos"

:persistent ->
'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'
~c"urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"

:transient ->
'urn:oasis:names:tc:SAML:2.0:nameid-format:transient'
~c"urn:oasis:names:tc:SAML:2.0:nameid-format:transient"

invalid_nameid_format ->
Logger.error(
Expand Down
2 changes: 1 addition & 1 deletion lib/samly/provider.ex
Expand Up @@ -48,7 +48,7 @@ defmodule Samly.Provider do
value

unknown ->
Logger.warn(
Logger.warning(
"[Samly] invalid_data idp_id_from: #{inspect(unknown)}. Using :path_segment"
)

Expand Down
16 changes: 15 additions & 1 deletion lib/samly/state/ets.ex
Expand Up @@ -46,7 +46,7 @@ defmodule Samly.State.ETS do
@impl Samly.State.Store
def get_assertion(_conn, assertion_key, assertions_table) do
case :ets.lookup(assertions_table, assertion_key) do
[{^assertion_key, %Assertion{} = assertion}] -> assertion
[{^assertion_key, %Assertion{} = assertion}] -> validate_assertion_expiry(assertion)
_ -> nil
end
end
Expand All @@ -62,4 +62,18 @@ defmodule Samly.State.ETS do
:ets.delete(assertions_table, assertion_key)
conn
end

defp validate_assertion_expiry(
%Assertion{subject: %{notonorafter: not_on_or_after}} = assertion
) do
now = DateTime.utc_now()

case DateTime.from_iso8601(not_on_or_after) do
{:ok, not_on_or_after, _} ->
if DateTime.compare(now, not_on_or_after) == :lt, do: assertion, else: nil

_ ->
nil
end
end
end
16 changes: 15 additions & 1 deletion lib/samly/state/session.ex
Expand Up @@ -34,7 +34,7 @@ defmodule Samly.State.Session do
%{key: key} = opts

case Conn.get_session(conn, key) do
{^assertion_key, %Assertion{} = assertion} -> assertion
{^assertion_key, %Assertion{} = assertion} -> validate_assertion_expiry(assertion)
_ -> nil
end
end
Expand All @@ -50,4 +50,18 @@ defmodule Samly.State.Session do
%{key: key} = opts
Conn.delete_session(conn, key)
end

defp validate_assertion_expiry(
%Assertion{subject: %{notonorafter: not_on_or_after}} = assertion
) do
now = DateTime.utc_now()

case DateTime.from_iso8601(not_on_or_after) do
{:ok, not_on_or_after, _} ->
if DateTime.compare(now, not_on_or_after) == :lt, do: assertion, else: nil

_ ->
nil
end
end
end
6 changes: 3 additions & 3 deletions test/samly_idp_data_test.exs
Expand Up @@ -262,7 +262,7 @@ defmodule SamlyIdpDataTest do

test "nameid-format-in-metadata-but-not-config-should-use-metadata", %{sps: sps} do
%IdpData{} = idp_data = IdpData.load_provider(@idp_config1, sps)
assert idp_data.nameid_format == 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient'
assert idp_data.nameid_format == ~c"urn:oasis:names:tc:SAML:2.0:nameid-format:transient"
end

test "nameid-format-in-config-but-not-metadata-should-use-config", %{sps: sps} do
Expand All @@ -273,7 +273,7 @@ defmodule SamlyIdpDataTest do
})

%IdpData{} = idp_data = IdpData.load_provider(idp_config, sps)
assert idp_data.nameid_format == 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'
assert idp_data.nameid_format == ~c"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
end

test "nameid-format-in-metadata-and-config-should-use-config", %{sps: sps} do
Expand All @@ -283,7 +283,7 @@ defmodule SamlyIdpDataTest do
})

%IdpData{} = idp_data = IdpData.load_provider(idp_config, sps)
assert idp_data.nameid_format == 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'
assert idp_data.nameid_format == ~c"urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"
end

test "nameid-format-in-neither-metadata-nor-config-should-be-unknown", %{sps: sps} do
Expand Down
124 changes: 87 additions & 37 deletions test/samly_state_test.exs
Expand Up @@ -2,46 +2,96 @@ defmodule Samly.StateTest do
use ExUnit.Case, async: true
use Plug.Test

setup do
opts =
Plug.Session.init(
store: :cookie,
key: "_samly_state_test_session",
encryption_salt: "salty enc",
signing_salt: "salty signing",
key_length: 64
)

Samly.State.init(Samly.State.Session)

conn =
conn(:get, "/")
|> Plug.Session.call(opts)
|> fetch_session()

[conn: conn]
end
describe "With Session Cache" do
setup do
opts =
Plug.Session.init(
store: :cookie,
key: "_samly_state_test_session",
encryption_salt: "salty enc",
signing_salt: "salty signing",
key_length: 64
)

test "put/get assertion", %{conn: conn} do
assertion = %Samly.Assertion{}
assertion_key = {"idp1", "name1"}
conn = Samly.State.put_assertion(conn, assertion_key, assertion)
assert assertion == Samly.State.get_assertion(conn, assertion_key)
end
Samly.State.init(Samly.State.Session)

conn =
conn(:get, "/")
|> Plug.Session.call(opts)
|> fetch_session()

[conn: conn]
end

test "put/get assertion", %{conn: conn} do
not_on_or_after = DateTime.utc_now() |> DateTime.add(8, :hour) |> DateTime.to_iso8601()
assertion = %Samly.Assertion{subject: %{notonorafter: not_on_or_after}}
assertion_key = {"idp1", "name1"}
conn = Samly.State.put_assertion(conn, assertion_key, assertion)
assert assertion == Samly.State.get_assertion(conn, assertion_key)
end

test "get failure for unknown assertion key", %{conn: conn} do
assertion = %Samly.Assertion{}
assertion_key = {"idp1", "name1"}
conn = Samly.State.put_assertion(conn, assertion_key, assertion)
assert is_nil(Samly.State.get_assertion(conn, {"idp1", "name2"}))
end

test "get failure for unknown assertion key", %{conn: conn} do
assertion = %Samly.Assertion{}
assertion_key = {"idp1", "name1"}
conn = Samly.State.put_assertion(conn, assertion_key, assertion)
assert nil == Samly.State.get_assertion(conn, {"idp1", "name2"})
test "get failure for expired assertion key", %{conn: conn} do
assertion = %Samly.Assertion{}
assertion_key = {"idp1", "name1"}
conn = Samly.State.put_assertion(conn, assertion_key, assertion)
assert is_nil(Samly.State.get_assertion(conn, {"idp1", "name1"}))
end

test "delete assertion", %{conn: conn} do
not_on_or_after = DateTime.utc_now() |> DateTime.add(8, :hour) |> DateTime.to_iso8601()
assertion = %Samly.Assertion{subject: %{notonorafter: not_on_or_after}}
assertion_key = {"idp1", "name1"}
conn = Samly.State.put_assertion(conn, assertion_key, assertion)
assert assertion == Samly.State.get_assertion(conn, assertion_key)
conn = Samly.State.delete_assertion(conn, assertion_key)
assert is_nil(Samly.State.get_assertion(conn, assertion_key))
end
end

test "delete assertion", %{conn: conn} do
assertion = %Samly.Assertion{}
assertion_key = {"idp1", "name1"}
conn = Samly.State.put_assertion(conn, assertion_key, assertion)
assert assertion == Samly.State.get_assertion(conn, assertion_key)
conn = Samly.State.delete_assertion(conn, assertion_key)
assert nil == Samly.State.get_assertion(conn, assertion_key)
describe "With ETS Cache" do
setup do
Samly.State.init(Samly.State.ETS)
[conn: conn(:get, "/")]
end

test "put/get assertion", %{conn: conn} do
not_on_or_after = DateTime.utc_now() |> DateTime.add(8, :hour) |> DateTime.to_iso8601()
assertion = %Samly.Assertion{subject: %{notonorafter: not_on_or_after}}
assertion_key = {"idp1", "name1"}
conn = Samly.State.put_assertion(conn, assertion_key, assertion)
assert assertion == Samly.State.get_assertion(conn, assertion_key)
end

test "get failure for unknown assertion key", %{conn: conn} do
assertion = %Samly.Assertion{}
assertion_key = {"idp1", "name1"}
conn = Samly.State.put_assertion(conn, assertion_key, assertion)
assert is_nil(Samly.State.get_assertion(conn, {"idp1", "name2"}))
end

test "get failure for expired assertion key", %{conn: conn} do
assertion = %Samly.Assertion{}
assertion_key = {"idp1", "name1"}
conn = Samly.State.put_assertion(conn, assertion_key, assertion)
assert is_nil(Samly.State.get_assertion(conn, {"idp1", "name1"}))
end

test "delete assertion", %{conn: conn} do
not_on_or_after = DateTime.utc_now() |> DateTime.add(8, :hour) |> DateTime.to_iso8601()
assertion = %Samly.Assertion{subject: %{notonorafter: not_on_or_after}}
assertion_key = {"idp1", "name1"}
conn = Samly.State.put_assertion(conn, assertion_key, assertion)
assert assertion == Samly.State.get_assertion(conn, assertion_key)
conn = Samly.State.delete_assertion(conn, assertion_key)
assert is_nil(Samly.State.get_assertion(conn, assertion_key))
end
end
end

0 comments on commit 7637ebe

Please sign in to comment.