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

Support signed assertions #45

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- Replaced `x509Certificate` with `x509Certificates` in `IDPSSODescriptor` so that it may have more than one certificate ([#65](https://github.com/mbg/wai-saml2/pull/65) by [@fumieval](https://github.com/fumieval))
- Added `attributeValues` to `AssertionAttribute` in order to handle multiple attribute values with the same name ([#67](https://github.com/mbg/wai-saml2/pull/67) by [@fumieval](https://github.com/fumieval))
- Support signed assertions, not just signed responses by ([#45](https://github.com/mbg/wai-saml2/pull/45) by [@fumieval](https://github.com/mbg/wai-saml2))
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- Support signed assertions, not just signed responses by ([#45](https://github.com/mbg/wai-saml2/pull/45) by [@fumieval](https://github.com/mbg/wai-saml2))
- Support signed assertions, not just signed responses ([#45](https://github.com/mbg/wai-saml2/pull/45) by [@fumieval](https://github.com/fumieval))

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I am happy for you to keep this as is.


## 0.6

Expand Down
9 changes: 7 additions & 2 deletions src/Network/Wai/SAML2/Assertion.hs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import Data.Time
import Text.XML.Cursor

import Network.Wai.SAML2.NameIDFormat
import Network.Wai.SAML2.Signature
import Network.Wai.SAML2.XML

--------------------------------------------------------------------------------
Expand Down Expand Up @@ -278,7 +279,9 @@ data Assertion = Assertion {
-- | The authentication statement included in the assertion.
assertionAuthnStatement :: !AuthnStatement,
-- | The assertion's attribute statement.
assertionAttributeStatement :: !AttributeStatement
assertionAttributeStatement :: !AttributeStatement,
-- | The assertion's signature.
assertionSignature :: !(Maybe Signature)
} deriving (Eq, Show)

-- Reference [Assertion]
Expand Down Expand Up @@ -306,7 +309,9 @@ instance FromXML Assertion where
assertionAuthnStatement = authnStatement,
assertionAttributeStatement =
cursor $/ element (saml2Name "AttributeStatement")
>=> parseAttributeStatement
>=> parseAttributeStatement,
assertionSignature = listToMaybe $
cursor $/ element (dsName "Signature") >=> parseXML
}

--------------------------------------------------------------------------------
Expand Down
8 changes: 3 additions & 5 deletions src/Network/Wai/SAML2/Response.hs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ data Response = Response {
-- | The status of the response.
responseStatusCode :: !StatusCode,
-- | The response signature.
responseSignature :: !Signature,
responseSignature :: !(Maybe Signature),
-- | The unencrypted assertion.
--
-- @since 0.4
Expand Down Expand Up @@ -88,9 +88,6 @@ instance FromXML Response where
$/ element (saml2Name "EncryptedAssertion")
) >>= parseXML

signature <- oneOrFail "Signature is required" (
cursor $/ element (dsName "Signature") ) >>= parseXML

pure Response{
responseDestination = T.concat $ attribute "Destination" cursor,
responseId = T.concat $ attribute "ID" cursor,
Expand All @@ -100,7 +97,8 @@ instance FromXML Response where
responseIssuer = T.concat $
cursor $/ element (saml2Name "Issuer") &/ content,
responseStatusCode = statusCode,
responseSignature = signature,
responseSignature = listToMaybe $
(cursor $/ element (dsName "Signature")) >>= parseXML,
responseAssertion = assertion,
responseEncryptedAssertion = encAssertion
}
Expand Down
113 changes: 86 additions & 27 deletions src/Network/Wai/SAML2/Validation.hs
Original file line number Diff line number Diff line change
Expand Up @@ -83,16 +83,10 @@ decodeResponse responseData = do
Left err -> throwError $ InvalidResponse err
Right samlResponse -> pure (responseXmlDoc, samlResponse)

-- | 'validateSAMLResponse' @cfg doc response timestamp@ validates a decoded SAML2
-- response using the given @timestamp@.
-- | 'validateSAMLPreliminary' @cfg samlResponse@ validates the status code, destination, and issuer of a SAML2 response.
--
-- @since 0.4
validateSAMLResponse :: SAML2Config
-> XML.Document
-> Response
-> UTCTime
-> ExceptT SAML2Error IO Assertion
validateSAMLResponse cfg responseXmlDoc samlResponse now = do
validateSAMLPreliminary :: SAML2Config -> Response -> ExceptT SAML2Error IO ()
validateSAMLPreliminary cfg samlResponse = do

-- check that the response indicates success
case statusCodeValue $ responseStatusCode samlResponse of
Expand All @@ -118,6 +112,29 @@ validateSAMLResponse cfg responseXmlDoc samlResponse now = do
| issuer /= expectedIssuer -> throwError $ InvalidIssuer issuer
_ -> pure ()

-- | 'validateSAMLResponse' @cfg doc response timestamp@ validates a decoded SAML2
-- response using the given @timestamp@.
--
-- @since 0.4
validateSAMLResponse :: SAML2Config
-> XML.Document
-> Response
-> UTCTime
-> ExceptT SAML2Error IO Assertion
validateSAMLResponse cfg responseXmlDoc samlResponse now = do

validateSAMLPreliminary cfg samlResponse

case responseSignature samlResponse of
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am wondering to what extent the path that's taken here should be up to the contents of the response? Rather than picking this based on whether there's a responseSignature present, would it make sense to instead have a setting in SAML2Config that determines which path we take here?

Just _ -> validateSAMLResponseSignature cfg responseXmlDoc samlResponse now
Nothing -> validateSAMLAssertionSignature cfg responseXmlDoc samlResponse now

validateSAMLResponseSignature :: SAML2Config
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good to add a docs comment for this function summarising what it does.

-> XML.Document
-> Response
-> UTCTime
-> ExceptT SAML2Error IO Assertion
validateSAMLResponseSignature cfg responseXmlDoc samlResponse now = do
-- ***CORE VALIDATION***
-- See https://www.w3.org/TR/xmldsig-core1/#sec-CoreValidation
--
Expand All @@ -126,19 +143,9 @@ validateSAMLResponse cfg responseXmlDoc samlResponse now = do
-- Signature element. This element contains
signedInfo <- extractSignedInfo (XML.fromDocument responseXmlDoc)

-- construct a new XML document from the SignedInfo element and render
-- it into a textual representation
let doc = XML.Document (XML.Prologue [] Nothing []) signedInfo []
let signedInfoXml = XML.renderLBS def doc

-- canonicalise the textual representation of the SignedInfo element
let prefixList = extractPrefixList (XML.fromDocument doc)
signedInfoCanonResult <- liftIO $ try $
canonicalise prefixList (LBS.toStrict signedInfoXml)

normalisedSignedInfo <- case signedInfoCanonResult of
Left err -> throwError $ CanonicalisationFailure err
Right result -> pure result
signature <- case responseSignature samlResponse of
Just sig -> pure sig
Nothing -> throwError $ InvalidResponse $ userError "Response Signature is required"
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This wouldn't be necessary if you make the Signature a parameter of validateSAMLResponseSignature and then pass it in as an argument in the Just case in validateSAMLResponse


-- 2. At this point we should dereference all elements identified by
-- Reference elements inside the SignedInfo element. However, we do
Expand All @@ -149,8 +156,7 @@ validateSAMLResponse cfg responseXmlDoc samlResponse now = do
let documentId = responseId samlResponse
let referenceId = referenceURI
$ signedInfoReference
$ signatureInfo
$ responseSignature samlResponse
$ signatureInfo signature

if documentId /= referenceId
then throwError $ UnexpectedReference referenceId
Expand All @@ -162,6 +168,34 @@ validateSAMLResponse cfg responseXmlDoc samlResponse now = do
-- the Signature element present). First remove the Signature element:
let docMinusSignature = removeSignature responseXmlDoc

validateSAMLSignature ValidationContext{..}

data ValidationContext = ValidationContext
{ cfg :: !SAML2Config
, docMinusSignature :: !XML.Document
, now :: !UTCTime
, responseXmlDoc :: !XML.Document
, samlResponse :: !Response
, signature :: !Signature
, signedInfo :: !XML.Element
}

validateSAMLSignature :: ValidationContext -> ExceptT SAML2Error IO Assertion
validateSAMLSignature ValidationContext{..} = do
-- construct a new XML document from the SignedInfo element and render
-- it into a textual representation
let doc = XML.Document (XML.Prologue [] Nothing []) signedInfo []
let signedInfoXml = XML.renderLBS def doc

-- canonicalise the textual representation of the SignedInfo element
let prefixList = extractPrefixList (XML.fromDocument doc)
signedInfoCanonResult <- liftIO $ try $
canonicalise prefixList (LBS.toStrict signedInfoXml)

normalisedSignedInfo <- case signedInfoCanonResult of
Left err -> throwError $ CanonicalisationFailure err
Right result -> pure result

-- then render the resulting document and canonicalise it
let renderedXml = XML.renderLBS def docMinusSignature
refCanonResult <- liftIO $ try $ canonicalise prefixList (LBS.toStrict renderedXml)
Expand All @@ -180,8 +214,7 @@ validateSAMLResponse cfg responseXmlDoc samlResponse now = do
$ BS.decodeLenient
$ referenceDigestValue
$ signedInfoReference
$ signatureInfo
$ responseSignature samlResponse
$ signatureInfo signature

if Just documentHash /= referenceHash
then throwError InvalidDigest
Expand All @@ -191,7 +224,7 @@ validateSAMLResponse cfg responseXmlDoc samlResponse now = do
-- We need to check that the SignedInfo element has not been tampered
-- with, which we do by checking the signature contained in the response;
-- first: extract the signature data from the response
let sig = BS.decodeLenient $ signatureValue $ responseSignature samlResponse
let sig = BS.decodeLenient $ signatureValue signature

-- using the IdP's public key and the canonicalised SignedInfo element,
-- check that the signature is correct
Expand Down Expand Up @@ -233,6 +266,32 @@ validateSAMLResponse cfg responseXmlDoc samlResponse now = do
-- all checks out, return the assertion
pure assertion

validateSAMLAssertionSignature :: SAML2Config -> XML.Document -> Response -> UTCTime -> ExceptT SAML2Error IO Assertion
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add a docs comment for this as well?

It may also make sense to move this function in the file to come after validateSAMLResponseSignature since both that function and this one are different control flow paths from validateSAMLResponse to validateSAMLSignature.

validateSAMLAssertionSignature cfg responseXmlDoc samlResponse now = do
assertion <- case responseAssertion samlResponse of
Just a -> pure a
_ -> throwError $ InvalidResponse $ userError "Assertion is required"

signature <- case assertionSignature assertion of
Just a -> pure a
_ -> throwError $ InvalidResponse $ userError "Assertion signature is required"
Comment on lines +271 to +277
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be more consistent to have more constructors in the SAML2Error type for these errors than to use InvalidResponse with userError.


-- Obtain the XML node of the assertion for validation
assertionXml <- oneOrFail "Assertion is required" $
XML.fromDocument responseXmlDoc XML.$/ XML.element (saml2Name "Assertion")
mbg marked this conversation as resolved.
Show resolved Hide resolved

signedInfo <- extractSignedInfo assertionXml

docMinusSignature <- removeSignature <$> case XML.node assertionXml of
XML.NodeElement node -> pure XML.Document
{ documentPrologue = XML.Prologue [] Nothing []
, documentRoot = node
, documentEpilogue = []
}
_ -> throwError $ InvalidResponse $ userError "Assertion is required"
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This error message should probably say something else?


validateSAMLSignature ValidationContext{..}

-- | `decryptAssertion` @key encryptedAssertion@ decrypts the AES key in
-- @encryptedAssertion@ using `key`, then decrypts the contents using
-- the AES key.
Expand Down
3 changes: 1 addition & 2 deletions tests/Validation.hs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import Network.Wai.SAML2
import Network.Wai.SAML2.Validation
import System.FilePath
import Test.Tasty
import Test.Tasty.ExpectedFailure
import Test.Tasty.HUnit

-- | Get a public key from a X.509 certificate
Expand Down Expand Up @@ -46,7 +45,7 @@ tests :: TestTree
tests = testGroup "Validate SAML2 Response"
[ testCase "AzureAD signed response"
$ run "azuread.crt" "2023-05-10T01:20:00Z" "azuread-signed-response.xml"
, expectFail $ testCase "AzureAD signed assertion"
, testCase "AzureAD signed assertion"
$ run "azuread.crt" "2023-05-09T16:00:00Z" "azuread-signed-assertion.xml"
, testCase "Okta with AttributeStatement"
$ run "okta.crt" "2023-06-16T06:43:00.000Z" "okta-attributes.xml"
Expand Down
34 changes: 18 additions & 16 deletions tests/data/google.xml.expected
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,23 @@ Response
MkStatusCode
{ statusCodeValue = Success , statusCodeSubordinate = Nothing }
, responseSignature =
Signature
{ signatureInfo =
SignedInfo
{ signedInfoCanonicalisationMethod = C14N_EXC_1_0
, signedInfoSignatureMethod = RSA_SHA256
, signedInfoReference =
Reference
{ referenceURI = "_b9917f3478ff3f776cb351547379d0bc"
, referenceDigestMethod = DigestSHA256
, referenceDigestValue =
"z3YXe+aIiyAYbSGqxRiYPwTO38JNZad2WBgqinjiX8g="
}
}
, signatureValue =
"S6IAb+4yYyNVIfnqM7ZIrFSKbzi0RgVqk6qNL0ehzFZhcaArqN/8S0oCDrrc+0B2ygCkpbDKt03/\nvNIxq862VYa1Y39n8w0/eIKc+m3beNS6e+n316o3LCtSTPvgC2kE96Jh44MOUX9z/KIYxXiPGgdi\nnnlxK6L1dZGrMKKBxEFiBJMhFI42gWADyrUz311PDImM+rVhINJT9NyD1i1G++PILLer/BlGwg5n\nwNQHmjt4pV3m+8zMMIrfLjZT/YX8s7+1Xn1Y3bMzo6PnsbvS7OX1YGuN4cPn9f4MLu5Vcn9lWo/r\n+zuZay/pS0mEqTmeOOMGk4BOseiU/hAra5NDgQ=="
}
Just
Signature
{ signatureInfo =
SignedInfo
{ signedInfoCanonicalisationMethod = C14N_EXC_1_0
, signedInfoSignatureMethod = RSA_SHA256
, signedInfoReference =
Reference
{ referenceURI = "_b9917f3478ff3f776cb351547379d0bc"
, referenceDigestMethod = DigestSHA256
, referenceDigestValue =
"z3YXe+aIiyAYbSGqxRiYPwTO38JNZad2WBgqinjiX8g="
}
}
, signatureValue =
"S6IAb+4yYyNVIfnqM7ZIrFSKbzi0RgVqk6qNL0ehzFZhcaArqN/8S0oCDrrc+0B2ygCkpbDKt03/\nvNIxq862VYa1Y39n8w0/eIKc+m3beNS6e+n316o3LCtSTPvgC2kE96Jh44MOUX9z/KIYxXiPGgdi\nnnlxK6L1dZGrMKKBxEFiBJMhFI42gWADyrUz311PDImM+rVhINJT9NyD1i1G++PILLer/BlGwg5n\nwNQHmjt4pV3m+8zMMIrfLjZT/YX8s7+1Xn1Y3bMzo6PnsbvS7OX1YGuN4cPn9f4MLu5Vcn9lWo/r\n+zuZay/pS0mEqTmeOOMGk4BOseiU/hAra5NDgQ=="
}
, responseAssertion =
Just
Assertion
Expand Down Expand Up @@ -72,6 +73,7 @@ Response
, authnStatementLocality = ""
}
, assertionAttributeStatement = []
, assertionSignature = Nothing
}
, responseEncryptedAssertion = Nothing
}
33 changes: 17 additions & 16 deletions tests/data/keycloak.xml.expected
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,23 @@ Response
MkStatusCode
{ statusCodeValue = Success , statusCodeSubordinate = Nothing }
, responseSignature =
Signature
{ signatureInfo =
SignedInfo
{ signedInfoCanonicalisationMethod = C14N_EXC_1_0
, signedInfoSignatureMethod = RSA_SHA256
, signedInfoReference =
Reference
{ referenceURI = "ID_5b1d000b-3a5e-4dfe-aa4e-b7bf1e3efbfd"
, referenceDigestMethod = DigestSHA256
, referenceDigestValue =
"/U47P3hsUf+tUyyglYF8M1u6lbVnHimCthtxusju4mo="
}
}
, signatureValue =
"b9vgIBQ1yNvUYgNmfAyuQJXOJ68PMfRvNAZEa93tnzZXHPEsf7/F49xI6/mlYI/T9pDxYnFcfl7kPMxgz4ssvMjwUEgAR3G3ZrNv4gPMUPmbZnXe0KG8yU9AskK90ya/T11kQfI21cSlA8FrLPTGP2X97yErR10mIDvEJ/m5dWra95cGLx/ntjaSIqNJpVgpHhRxieS4Lw+zeWe/nVuznXQnb8VRhCq18ikL/u23+YhYT3ws3iXQssJ2BosX9JJt0O+X31sIHJIWHsxbI69NLJ782bVDDkI1PNF8MKoa8gSEiLsNSmp3SyXtMPzaRIBguksl9xbnmYmsJDQg6kFVlQ=="
}
Just
Signature
{ signatureInfo =
SignedInfo
{ signedInfoCanonicalisationMethod = C14N_EXC_1_0
, signedInfoSignatureMethod = RSA_SHA256
, signedInfoReference =
Reference
{ referenceURI = "ID_5b1d000b-3a5e-4dfe-aa4e-b7bf1e3efbfd"
, referenceDigestMethod = DigestSHA256
, referenceDigestValue =
"/U47P3hsUf+tUyyglYF8M1u6lbVnHimCthtxusju4mo="
}
}
, signatureValue =
"b9vgIBQ1yNvUYgNmfAyuQJXOJ68PMfRvNAZEa93tnzZXHPEsf7/F49xI6/mlYI/T9pDxYnFcfl7kPMxgz4ssvMjwUEgAR3G3ZrNv4gPMUPmbZnXe0KG8yU9AskK90ya/T11kQfI21cSlA8FrLPTGP2X97yErR10mIDvEJ/m5dWra95cGLx/ntjaSIqNJpVgpHhRxieS4Lw+zeWe/nVuznXQnb8VRhCq18ikL/u23+YhYT3ws3iXQssJ2BosX9JJt0O+X31sIHJIWHsxbI69NLJ782bVDDkI1PNF8MKoa8gSEiLsNSmp3SyXtMPzaRIBguksl9xbnmYmsJDQg6kFVlQ=="
}
, responseAssertion = Nothing
, responseEncryptedAssertion =
Just
Expand Down
33 changes: 17 additions & 16 deletions tests/data/okta.xml.expected
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,23 @@ Response
MkStatusCode
{ statusCodeValue = Success , statusCodeSubordinate = Nothing }
, responseSignature =
Signature
{ signatureInfo =
SignedInfo
{ signedInfoCanonicalisationMethod = C14N_EXC_1_0
, signedInfoSignatureMethod = RSA_SHA256
, signedInfoReference =
Reference
{ referenceURI = "id36905492230634463108368061"
, referenceDigestMethod = DigestSHA256
, referenceDigestValue =
"i300U58mYoywmpgMkq7AHOplar4B6ZwW++Ri3PUvGlc="
}
}
, signatureValue =
"eKymDiZIxFd+oJCIhdHQpI+s2nQYFBVH7TBoZaqXizJdNnNPpNlKM5wds33zP6a72GWOL/2n8WRtW+xSnPTmw5PumEuUhWbO2EizfU/SdBL4HKxxJt0nntkDRhdIoHBi+9Gy80So4NGr82B2clnhpj74GW0Ko8A0bt2sHzdQ9NaJ5ru4Qvx0Fk/UCBAGAYobryjAc9Tb5qxxgHGMmPUHG5CprRYTINKZlGF1Jl88VSdTbOYmGjJ4b6GY8pLR2qLt4LErVPUSI4vEUuPFU2f/9/i8D39hzWtJOCzuaUZ5tdKU3Fyjsgoa7ooDfZVzb4howWhQ9zPUgwjZEd9tbW2EwA=="
}
Just
Signature
{ signatureInfo =
SignedInfo
{ signedInfoCanonicalisationMethod = C14N_EXC_1_0
, signedInfoSignatureMethod = RSA_SHA256
, signedInfoReference =
Reference
{ referenceURI = "id36905492230634463108368061"
, referenceDigestMethod = DigestSHA256
, referenceDigestValue =
"i300U58mYoywmpgMkq7AHOplar4B6ZwW++Ri3PUvGlc="
}
}
, signatureValue =
"eKymDiZIxFd+oJCIhdHQpI+s2nQYFBVH7TBoZaqXizJdNnNPpNlKM5wds33zP6a72GWOL/2n8WRtW+xSnPTmw5PumEuUhWbO2EizfU/SdBL4HKxxJt0nntkDRhdIoHBi+9Gy80So4NGr82B2clnhpj74GW0Ko8A0bt2sHzdQ9NaJ5ru4Qvx0Fk/UCBAGAYobryjAc9Tb5qxxgHGMmPUHG5CprRYTINKZlGF1Jl88VSdTbOYmGjJ4b6GY8pLR2qLt4LErVPUSI4vEUuPFU2f/9/i8D39hzWtJOCzuaUZ5tdKU3Fyjsgoa7ooDfZVzb4howWhQ9zPUgwjZEd9tbW2EwA=="
}
, responseAssertion = Nothing
, responseEncryptedAssertion =
Just
Expand Down