defmodule Relyra.Protocol.Assertion do
@moduledoc false
alias Relyra.Error
@default_skew_seconds 120
alias Relyra.Provider
@spec validate_audience(term(), term(), map() | nil) :: :ok | {:error, Error.t()}
def validate_audience(actual_audiences, expected_audience, connection \\ nil) do
normalized_expected = normalize(expected_audience)
normalized_audiences = normalize_audiences(actual_audiences)
if normalized_expected in normalized_audiences do
:ok
else
details = %{
expected: normalized_expected,
actual: normalized_audiences
}
details =
case Provider.hint_for(connection, :sp_entity_id) do
nil -> details
hint -> Map.put(details, :provider_hint, hint)
end
{:error,
Error.new(
:invalid_audience,
"Assertion audience does not include expected audience",
details
)}
end
end
@spec validate_recipient(term(), term()) :: :ok | {:error, Error.t()}
def validate_recipient(actual_recipient, expected_recipient) do
if normalize(actual_recipient) == normalize(expected_recipient) do
:ok
else
{:error,
Error.new(
:recipient_mismatch,
"Assertion recipient does not match expected ACS recipient",
%{
expected: normalize(expected_recipient),
actual: normalize(actual_recipient)
}
)}
end
end
@spec validate_time_conditions(map(), DateTime.t(), keyword()) :: :ok | {:error, Error.t()}
def validate_time_conditions(assertion_times, now, opts \\ [])
def validate_time_conditions(assertion_times, %DateTime{} = now, opts)
when is_map(assertion_times) do
case normalize_skew(opts) do
{:ok, skew_seconds} ->
with {:ok, not_before} <- fetch_datetime(assertion_times, :not_before),
{:ok, not_on_or_after} <- fetch_datetime(assertion_times, :not_on_or_after),
{:ok, subject_confirmation_not_on_or_after} <-
fetch_datetime(assertion_times, :subject_confirmation_not_on_or_after),
:ok <- validate_not_before(not_before, now, skew_seconds),
:ok <- validate_not_on_or_after(not_on_or_after, now, skew_seconds),
:ok <-
validate_subject_confirmation_not_on_or_after(
subject_confirmation_not_on_or_after,
now,
skew_seconds
) do
:ok
end
{:error, %Error{} = error} ->
{:error, error}
end
end
def validate_time_conditions(_assertion_times, _now, _opts) do
{:error,
Error.new(
:clock_skew_exceeded,
"Assertion time conditions must be map/date-time inputs",
%{expected: %{assertion_times: :map, now: :datetime}, actual: :invalid_inputs}
)}
end
defp validate_not_before(not_before, now, skew_seconds) do
threshold = DateTime.add(now, skew_seconds, :second)
case DateTime.compare(not_before, threshold) do
:gt ->
{:error,
Error.new(
:assertion_not_yet_valid,
"Assertion NotBefore is outside accepted clock skew window",
%{
expected: DateTime.to_iso8601(threshold),
actual: DateTime.to_iso8601(not_before)
}
)}
_ ->
:ok
end
end
defp validate_not_on_or_after(not_on_or_after, now, skew_seconds) do
threshold = DateTime.add(not_on_or_after, skew_seconds, :second)
case DateTime.compare(now, threshold) do
:gt ->
{:error,
Error.new(
:assertion_expired,
"Assertion NotOnOrAfter is outside accepted clock skew window",
%{
expected: DateTime.to_iso8601(threshold),
actual: DateTime.to_iso8601(now)
}
)}
_ ->
:ok
end
end
defp validate_subject_confirmation_not_on_or_after(
subject_confirmation_not_on_or_after,
now,
skew_seconds
) do
threshold = DateTime.add(subject_confirmation_not_on_or_after, skew_seconds, :second)
case DateTime.compare(now, threshold) do
:gt ->
{:error,
Error.new(
:subject_confirmation_expired,
"SubjectConfirmationData.NotOnOrAfter is outside accepted clock skew window",
%{
expected: DateTime.to_iso8601(threshold),
actual: DateTime.to_iso8601(now)
}
)}
_ ->
:ok
end
end
defp normalize_skew(opts) do
skew_seconds = Keyword.get(opts, :skew_seconds, @default_skew_seconds)
if is_integer(skew_seconds) and skew_seconds >= 0 do
{:ok, skew_seconds}
else
{:error,
Error.new(
:clock_skew_exceeded,
"Clock skew must be a non-negative integer",
%{expected: :non_negative_integer, actual: skew_seconds}
)}
end
end
defp fetch_datetime(assertion_times, key) do
value =
Map.get(assertion_times, key) ||
Map.get(assertion_times, Atom.to_string(key))
parse_datetime(value, key)
end
defp parse_datetime(%DateTime{} = datetime, _key), do: {:ok, datetime}
defp parse_datetime(value, key) when is_binary(value) do
case DateTime.from_iso8601(value) do
{:ok, datetime, _offset} ->
{:ok, datetime}
_ ->
{:error,
Error.new(
:clock_skew_exceeded,
"Assertion time condition must be an ISO8601 datetime",
%{
expected: key,
actual: value
}
)}
end
end
defp parse_datetime(value, key) do
{:error,
Error.new(
:clock_skew_exceeded,
"Assertion time condition is missing or invalid",
%{
expected: key,
actual: value
}
)}
end
defp normalize_audiences(value) when is_binary(value), do: [value]
defp normalize_audiences(values) when is_list(values) do
values
|> Enum.map(&normalize/1)
|> Enum.reject(&is_nil/1)
end
defp normalize_audiences(_value), do: []
defp normalize(value) when is_binary(value), do: value
defp normalize(value) when is_atom(value), do: Atom.to_string(value)
defp normalize(value), do: value
end