Skip to content

Commit

Permalink
Add parser for SAML response
Browse files Browse the repository at this point in the history
  • Loading branch information
LauraBeatris committed Mar 24, 2024
1 parent 9d0a7e1 commit a0372b0
Show file tree
Hide file tree
Showing 13 changed files with 275 additions and 30 deletions.
34 changes: 33 additions & 1 deletion lib/saml.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ defmodule ShinAuth.SAML do
"""

alias ShinAuth.SAML.Request
alias ShinAuth.SAML.Response

@doc """
Performs decoding on a given SAML request XML document
"""
@spec decode_saml_request(discovery_endpoint :: :uri_string.uri_string()) ::
@spec decode_saml_request(saml_request_xml :: String.t()) ::
{:ok, Request.t()}
| {:error, Request.Error.t()}

Expand Down Expand Up @@ -38,6 +39,37 @@ defmodule ShinAuth.SAML do
end
end

@doc """
Performs decoding on a given SAML response XML document
"""
@spec decode_saml_response(saml_response_xml :: String.t()) ::
{:ok, Response.t()}
| {:error, Response.Error.t()}

def decode_saml_response(""),
do:
{:error,
%Response.Error{
tag: :malformed_saml_response,
message: "Verify if the SAML response is structured correctly by the Identity Provider."
}}

def decode_saml_response(saml_response) do
error = %Response.Error{
tag: :malformed_saml_response,
message: "Verify if the SAML response is structured correctly by the Identity Provider."
}

if valid_xml?(saml_response) do
case DataSchema.to_struct(saml_response, Response) do
{:ok, parsed_saml_response} -> {:ok, parsed_saml_response}
{:error, _} = error -> error
end
else
{:error, error}
end
end

defp valid_xml?(xml_string) do
SweetXml.parse(xml_string, quiet: true)
true
Expand Down
4 changes: 2 additions & 2 deletions lib/saml/request/request.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
defmodule ShinAuth.SAML.Request do
@moduledoc """
Parsed XML struct of a SAML Request
Parsed XML struct of a SAML request
"""

import DataSchema, only: [data_schema: 1]
Expand All @@ -14,7 +14,7 @@ defmodule ShinAuth.SAML.Request do
| {:issuer, String.t()}
| {:issue_instant, String.t()}

@data_accessor ShinAuth.SAML.Request.XMLHandler
@data_accessor ShinAuth.SAML.XMLHandler
data_schema(
field: {:id, "/samlp:AuthnRequest/@ID", &{:ok, Utils.maybe_to_string(&1)}, optional: false},
field:
Expand Down
12 changes: 12 additions & 0 deletions lib/saml/response/error.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
defmodule ShinAuth.SAML.Response.Error do
@type t :: %__MODULE__{
tag: :malformed_saml_response,
message: String.t()
}

defexception [:tag, :message]

def message(%{tag: _tag, message: message}) do
message
end
end
33 changes: 33 additions & 0 deletions lib/saml/response/response.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
defmodule ShinAuth.SAML.Response do
@moduledoc """
Parsed XML struct of a SAML response
"""

import DataSchema, only: [data_schema: 1]

alias ShinAuth.SAML.Response.Utils

@data_accessor ShinAuth.SAML.XMLHandler
data_schema(
field: {:id, "/saml2p:Response/@ID", &{:ok, Utils.maybe_to_string(&1)}, optional: false},
field:
{:version, "/saml2p:Response/@Version", &{:ok, Utils.maybe_to_string(&1)}, optional: false},
field:
{:destination, "/saml2p:Response/@Destination", &{:ok, Utils.maybe_to_string(&1)},
optional: false},
field:
{:issuer, "/saml2p:Response/saml2p:Issuer/text()", &{:ok, Utils.maybe_to_string(&1)},
optional: false},
field:
{:issue_instant, "/saml2p:Response/@IssueInstant", &{:ok, Utils.maybe_to_string(&1)},
optional: false}
)
end

defmodule ShinAuth.SAML.Response.Utils do
@moduledoc false

def maybe_to_string(""), do: nil
def maybe_to_string(nil), do: nil
def maybe_to_string(value), do: to_string(value) |> String.trim()
end
2 changes: 1 addition & 1 deletion lib/saml/request/xml_handler.ex → lib/xml_handler.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
defmodule ShinAuth.SAML.Request.XMLHandler do
defmodule ShinAuth.SAML.XMLHandler do
@moduledoc false

@behaviour DataSchema.DataAccessBehaviour
Expand Down
4 changes: 1 addition & 3 deletions test/oidc_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ defmodule ShinAuth.OIDCTest do
alias ShinAuth.OIDC.ProviderConfiguration.Error
alias ShinAuth.OIDC.ProviderConfiguration.Metadata

doctest ShinAuth

@valid_discovery_endpoint "https://valid-url/.well-known/openid-configuration"
@http_client ShinAuth.HTTPClientMock

Expand Down Expand Up @@ -129,7 +127,7 @@ defmodule ShinAuth.OIDCTest do
end

defp get_json(filename) do
Path.join(__DIR__, ["support/", filename <> ".json"])
Path.join(__DIR__, ["support/oidc/", filename <> ".json"])
|> File.read!()
end

Expand Down
43 changes: 20 additions & 23 deletions test/saml/request_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -28,41 +28,38 @@ defmodule ShinAuth.SAML.RequestTest do
end
end

context "when missing saml request required element" do
saml_request_mock = """
<?xml version="1.0"?>
<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="_123" Version="2.0" IssueInstant="2023-09-27T17:20:42.746Z" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Destination="https://accounts.google.com/o/saml2/idp?idpid=C03gu405z" AssertionConsumerServiceURL="https://auth.example.com/sso/saml/acs/123">
<samlp:NameIDPolicy Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" AllowCreate="true"/>
</samlp:AuthnRequest>
"""
context "when missing required element" do
it "returns error" do
saml_request_mock = """
<?xml version="1.0"?>
<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="_123" Version="2.0" IssueInstant="2023-09-27T17:20:42.746Z" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Destination="https://accounts.google.com/o/saml2/idp?idpid=C03gu405z" AssertionConsumerServiceURL="https://auth.example.com/sso/saml/acs/123">
<samlp:NameIDPolicy Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" AllowCreate="true"/>
</samlp:AuthnRequest>
"""

{:error,
%DataSchema.Errors{
errors: [issuer: "Field was required but value supplied is considered empty"]
}} = SAML.decode_saml_request(saml_request_mock)
{:error,
%DataSchema.Errors{
errors: [issuer: "Field was required but value supplied is considered empty"]
}} = SAML.decode_saml_request(saml_request_mock)
end
end
end

describe "with valid saml request" do
context "returns parsed request struct" do
saml_request_mock = """
<?xml version="1.0"?>
<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="_123" Version="2.0" IssueInstant="2023-09-27T17:20:42.746Z" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Destination="https://accounts.google.com/o/saml2/idp?idpid=C03gu405z" AssertionConsumerServiceURL="https://auth.example.com/sso/saml/acs/123">
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
https://example.com/123
</saml:Issuer>
<samlp:NameIDPolicy Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" AllowCreate="true"/>
</samlp:AuthnRequest>
"""

it "returns parsed request struct" do
{:ok,
%Request{
id: "_123",
version: "2.0",
issue_instant: "2023-09-27T17:20:42.746Z",
issuer: "https://example.com/123",
assertion_consumer_service_url: "https://auth.example.com/sso/saml/acs/123"
}} = SAML.decode_saml_request(saml_request_mock)
}} = SAML.decode_saml_request(get_xml("valid_saml_request"))
end
end

defp get_xml(filename) do
Path.join([__DIR__, "../support/saml", filename <> ".xml"])
|> File.read!()
end
end
62 changes: 62 additions & 0 deletions test/saml/response_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
defmodule ShinAuth.SAML.ResponseTest do
use ExSpec, async: true

alias ShinAuth.SAML
alias ShinAuth.SAML.Response

describe "with malformed saml response" do
context "with empty xml" do
it "returns error" do
{:error,
%Response.Error{
tag: :malformed_saml_response,
message:
"Verify if the SAML response is structured correctly by the Identity Provider."
}} = SAML.decode_saml_response("")
end
end

context "with malformed xml element" do
it "returns error" do
{:error,
%Response.Error{
tag: :malformed_saml_response,
message:
"Verify if the SAML response is structured correctly by the Identity Provider."
}} =
SAML.decode_saml_response("""
<Invalid
""")
end
end

context "when missing required element" do
it "returns error" do
{:error,
%Response.Error{
tag: :malformed_saml_response,
message:
"Verify if the SAML response is structured correctly by the Identity Provider."
}} = SAML.decode_saml_response(get_xml("saml_response_missing_element"))
end
end
end

describe "with valid saml response" do
it "returns parsed response struct" do
{:ok,
%Response{
id: "_123",
version: "2.0",
destination: "https://api.example.com/sso/saml/acs/123",
issuer: "https://example.com/1234/issuer/1234",
issue_instant: "2024-03-23T20:56:56.768Z"
}} = SAML.decode_saml_response(get_xml("valid_saml_response"))
end
end

defp get_xml(filename) do
Path.join([__DIR__, "../support/saml", filename <> ".xml"])
|> File.read!()
end
end
File renamed without changes.
Empty file.
7 changes: 7 additions & 0 deletions test/support/saml/valid_saml_request.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version="1.0"?>
<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="_123" Version="2.0" IssueInstant="2023-09-27T17:20:42.746Z" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Destination="https://accounts.google.com/o/saml2/idp?idpid=C03gu405z" AssertionConsumerServiceURL="https://auth.example.com/sso/saml/acs/123">
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
https://example.com/123
</saml:Issuer>
<samlp:NameIDPolicy Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" AllowCreate="true"/>
</samlp:AuthnRequest>
104 changes: 104 additions & 0 deletions test/support/saml/valid_saml_response.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?xml version="1.0"?>
<saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:xs="http://www.w3.org/2001/XMLSchema"
Destination="https://api.example.com/sso/saml/acs/123"
ID="_123" IssueInstant="2024-03-23T20:56:56.768Z" Version="2.0">
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" />
<ds:Reference URI="#_01HSPHMC62R1YR6SCZHEJPF8ZK">
<ds:Transforms>
<ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature" />
<ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
</ds:Transforms>
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256" />
<ds:DigestValue>
/9nJSSrBIaU916FmShj4B6kX5IT7gMltDHWsSQO7d9I=
</ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue>
CnlmBvd0v+d87qOjyiCMlGMr49Wez0UOY439HXgoJmjBt5Q1KhuO8Akdiu6iimulFpeRvN/IU3A57DIJkCp3lfF+iiiN4UPyiq7ArgXmNEI9eOky/7e7S4Eau41yXOh9Di41yGtyEv+lq32RywkkbHldHU52iWpIMP9R8uleqS/1CSpsSVseP80xzPQaGXmjgWlA+KQhW7gQrUAr159C/pmYz2jQMB3pE8t8NW8futNpBz96ZKwlLcn8rjpOXFE8auRU8WbG+pby/+MzL0qatuJZUC7bHY916ovuKEeCW1Xfdy5O/kyQ+1qISO7kVMm1fBwaVpWk9XFPY9MkFTie+w==
</ds:SignatureValue>
</ds:Signature>
<saml2p:Issuer xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"
Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">
https://example.com/1234/issuer/1234
</saml2p:Issuer>
<saml2p:Status>
<saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success" />
</saml2p:Status>
<saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_01HSPHMC63GGBQTV59FH81SZQW" IssueInstant="2024-03-23T20:56:56.768Z" Version="2.0">
<ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:SignedInfo>
<ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
<ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" />
<ds:Reference URI="#_01HSPHMC63GGBQTV59FH81SZQW">
<ds:Transforms>
<ds:Transform
Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature" />
<ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
</ds:Transforms>
<ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256" />
<ds:DigestValue>
oS6NG0rhC7joVaqNmrgCZq+J8ewXWxuR92ICvfUWWJc=
</ds:DigestValue>
</ds:Reference>
</ds:SignedInfo>
<ds:SignatureValue>
bRaFe6yjlyZcTZVdD0++pIvmUXYB54RjU7SK2hNth23e4HjjEzm1CkP03DxcEgkHs2msjLuboVgypC84iB+5t4ntyvIfA1Xt7tSOGJZremF3bWa/1s6LigAYAczvnA2wDUG2ASZiD0IJQPj8Zy0uA0979wmquWCnFVcEIH0typ3P3qzHQimvCjbCChPnW3hwVc0v4zNmjHYn5Geb6khXuZ+dwOta9qF1jjRDpcZ1S4jmbURu6D46trb+xsQ1x983KzzWUNwUnUa0KnAgsenyp/NSGOY4lCagnhusYdh3cv6MCkb0VpWmtlVInWArMxKIyqFi8qKGgMh97MPsDR2Jrg==
</ds:SignatureValue>
</ds:Signature>
<saml2:Subject saml2:NameID="[object Object]" saml2:SubjectConfirmation="[object Object]" />
<saml2:Conditions NotBefore="2024-03-23T20:56:56.768Z"
NotOnOrAfter="2024-03-23T21:01:56.768Z">
<saml2:AudienceRestriction>
<saml2:Audience>
https://api.example.com/1234
</saml2:Audience>
</saml2:AudienceRestriction>
</saml2:Conditions>
<saml2:AuthnStatement AuthnInstant="2024-03-23T20:56:56.768Z"
SessionIndex="01HSPHMC65B90ZPRY1TCQFMPMJ">
<saml2:AuthnContext>
<saml2:AuthnContextClassRef>
urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport
</saml2:AuthnContextClassRef>
</saml2:AuthnContext>
</saml2:AuthnStatement>
<saml2:AttributeStatement>
<saml2:Attribute Name="id"
NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified">
<saml2:AttributeValue xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:type="xs:string">
209bac63df9962e7ec458951607ae2e8ed00445a
</saml2:AttributeValue>
</saml2:Attribute>
<saml2:Attribute Name="email"
NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified">
<saml2:AttributeValue xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:type="xs:string">
foo@corp.com
</saml2:AttributeValue>
</saml2:Attribute>
<saml2:Attribute Name="firstName"
NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified">
<saml2:AttributeValue xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:type="xs:string">
Laura
</saml2:AttributeValue>
</saml2:Attribute>
<saml2:Attribute Name="lastName"
NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified">
<saml2:AttributeValue xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:type="xs:string">
Beatris
</saml2:AttributeValue>
</saml2:Attribute>
<saml2:Attribute Name="groups"
NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified" />
</saml2:AttributeStatement>
</saml2:Assertion>
</saml2p:Response>

0 comments on commit a0372b0

Please sign in to comment.