Skip to content

Commit

Permalink
feat: port metadata generation off of hiccup
Browse files Browse the repository at this point in the history
Allows us to fully remove hiccup XML generation for just using the
opensaml library and clean up other dead code and tests from using the
library directly.

Adds tests for metadata generation.
  • Loading branch information
edpaget committed Feb 24, 2025
1 parent 200ec3b commit 1e4f26d
Show file tree
Hide file tree
Showing 11 changed files with 139 additions and 143 deletions.
1 change: 0 additions & 1 deletion deps.edn
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
com.onelogin/java-saml {:mvn/version "2.9.0"}
clojure.java-time/clojure.java-time {:mvn/version "1.4.2"}
commons-io/commons-io {:mvn/version "2.16.1"}
hiccup/hiccup {:mvn/version "1.0.5"}
org.opensaml/opensaml-core-api {:mvn/version "5.1.3"}
org.opensaml/opensaml-core-impl {:mvn/version "5.1.3"}
org.opensaml/opensaml-saml-impl {:mvn/version "5.1.3"}
Expand Down
8 changes: 0 additions & 8 deletions src/saml20_clj/coerce.clj
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
(ns saml20-clj.coerce
(:require [clojure.java.io :as io]
[clojure.string :as str]
[hiccup.core :as hiccup]
[hiccup.page :as h.page]
[saml20-clj.encode-decode :as encode-decode]
[saml20-clj.xml :as saml.xml])
(:import org.opensaml.core.xml.util.XMLObjectSupport))
Expand Down Expand Up @@ -293,12 +291,6 @@
String
(->xml-string [this] this)

clojure.lang.IPersistentVector
(->xml-string [this]
(str
(h.page/xml-declaration "UTF-8")
(hiccup/html this)))

org.w3c.dom.Node
(->xml-string [this]
(let [transformer (doto (.. javax.xml.transform.TransformerFactory newInstance newTransformer)
Expand Down
54 changes: 1 addition & 53 deletions src/saml20_clj/crypto.clj
Original file line number Diff line number Diff line change
@@ -1,31 +1,7 @@
(ns saml20-clj.crypto
(:require [saml20-clj.coerce :as coerce])
(:import org.apache.xml.security.Init
org.opensaml.security.credential.Credential
org.opensaml.xmlsec.signature.support.SignatureConstants))

(def signature-algorithms
{:dsa {nil SignatureConstants/ALGO_ID_SIGNATURE_DSA
:sha1 SignatureConstants/ALGO_ID_SIGNATURE_DSA_SHA1
:sha256 SignatureConstants/ALGO_ID_SIGNATURE_DSA_SHA256}
:rsa {nil SignatureConstants/ALGO_ID_SIGNATURE_RSA
:sha1 SignatureConstants/ALGO_ID_SIGNATURE_RSA_SHA1
:ripemd160 SignatureConstants/ALGO_ID_SIGNATURE_RSA_RIPEMD160
:sha256 SignatureConstants/ALGO_ID_SIGNATURE_RSA_SHA256
:sha224 SignatureConstants/ALGO_ID_SIGNATURE_RSA_SHA224
:sha384 SignatureConstants/ALGO_ID_SIGNATURE_RSA_SHA384
:sha512 SignatureConstants/ALGO_ID_SIGNATURE_RSA_SHA512}
:ecdsa {:sha1 SignatureConstants/ALGO_ID_SIGNATURE_ECDSA_SHA1
:sha224 SignatureConstants/ALGO_ID_SIGNATURE_ECDSA_SHA224
:sha256 SignatureConstants/ALGO_ID_SIGNATURE_ECDSA_SHA256
:sha384 SignatureConstants/ALGO_ID_SIGNATURE_ECDSA_SHA384
:sha512 SignatureConstants/ALGO_ID_SIGNATURE_ECDSA_SHA512}})

(def canonicalization-algorithms
{:omit-comments SignatureConstants/ALGO_ID_C14N_OMIT_COMMENTS
:with-comments SignatureConstants/ALGO_ID_C14N_WITH_COMMENTS
:excl-omit-comments SignatureConstants/ALGO_ID_C14N_EXCL_OMIT_COMMENTS
:excl-with-comments SignatureConstants/ALGO_ID_C14N_EXCL_WITH_COMMENTS})
org.opensaml.security.credential.Credential))

(defn has-private-key?
"Will check if the provided keystore contains a private key or not."
Expand All @@ -36,34 +12,6 @@
(coerce/->Credential (coerce/->PrivateKey credential))))]
(some? (.getPrivateKey credential))))

;; TODO -- I'm pretty sure this mutates `object`
(defn sign
^org.w3c.dom.Element [object credential & {:keys [signature-algorithm
canonicalization-algorithm]
:or {signature-algorithm [:rsa :sha256]
canonicalization-algorithm :excl-omit-comments}}]
(when-let [object (coerce/->SAMLObject object)]
(when-let [^Credential credential (try
(coerce/->Credential credential)
(catch Throwable _
(coerce/->Credential (coerce/->PrivateKey credential))))]
(let [signature (doto (.buildObject (org.opensaml.xmlsec.signature.impl.SignatureBuilder.))
(.setSigningCredential credential)
(.setSignatureAlgorithm (or (get-in signature-algorithms signature-algorithm)
(throw (ex-info "No matching signature algorithm"
{:algorithm signature-algorithm}))))
(.setCanonicalizationAlgorithm (or (get canonicalization-algorithms canonicalization-algorithm)
(throw (ex-info "No matching canonicalization algorithm"
{:algorithm canonicalization-algorithm})))))
key-info-gen (doto (new org.opensaml.xmlsec.keyinfo.impl.X509KeyInfoGeneratorFactory)
(.setEmitEntityCertificate true))]
(when-let [key-info (.generate (.newInstance key-info-gen) credential)] ; No need to test X509 coercion first
(.setKeyInfo signature key-info))
(.setSignature object signature)
(let [element (coerce/->Element object)]
(org.opensaml.xmlsec.signature.support.Signer/signObject signature)
element)))))

(defn decrypt! [sp-private-key element]
(when-let [sp-private-key (coerce/->PrivateKey sp-private-key)]
(when-let [element (coerce/->Element element)]
Expand Down
16 changes: 2 additions & 14 deletions src/saml20_clj/encode_decode.clj
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
(ns saml20-clj.encode-decode
"Utility functions for encoding/decoding and compressing byte arrays and strings."
(:require [clojure.string :as str])
(:import [java.io ByteArrayInputStream ByteArrayOutputStream]
[java.util.zip Deflater DeflaterOutputStream Inflater InflaterInputStream]
(:import java.io.ByteArrayInputStream
[java.util.zip Inflater InflaterInputStream]
[org.apache.commons.codec.binary Base64 Hex]
org.apache.commons.io.IOUtils))

Expand Down Expand Up @@ -36,14 +36,6 @@
(when s
(decode-base64 (str->bytes (strip-ascii-armor s)))))

(defn byte-deflate
^bytes [^bytes str-bytes]
(with-open [byte-os (ByteArrayOutputStream.)
deflater-os (DeflaterOutputStream. byte-os (Deflater. -1 true) 1024)]
(.write deflater-os str-bytes)
(.finish deflater-os)
(.toByteArray byte-os)))

(defn byte-inflate
^bytes [^bytes comp-bytes]
(with-open [is (InflaterInputStream. (ByteArrayInputStream. comp-bytes) (Inflater. true) 1024)]
Expand All @@ -53,10 +45,6 @@
^String [^String string]
(-> string str->bytes encode-base64 bytes->str))

(defn str->deflate->base64
^String [^String string]
(-> string str->bytes byte-deflate encode-base64 bytes->str))

(defn base64->str
^String [^String string]
(-> string str->bytes decode-base64 bytes->str))
Expand Down
91 changes: 55 additions & 36 deletions src/saml20_clj/sp/metadata.clj
Original file line number Diff line number Diff line change
@@ -1,42 +1,61 @@
(ns saml20-clj.sp.metadata
(:require [clojure.string :as str]
[saml20-clj.coerce :as coerce]
[saml20-clj.encode-decode :as encode]))
[saml20-clj.coerce :as coerce])
(:import org.opensaml.core.xml.util.XMLObjectSupport
org.opensaml.saml.common.xml.SAMLConstants
org.opensaml.saml.saml2.core.NameIDType
[org.opensaml.saml.saml2.metadata.impl AssertionConsumerServiceBuilder EntityDescriptorBuilder KeyDescriptorBuilder NameIDFormatBuilder SingleLogoutServiceBuilder SPSSODescriptorBuilder]
org.opensaml.security.credential.UsageType
org.opensaml.xmlsec.keyinfo.impl.X509KeyInfoGeneratorFactory))

(def ^:private name-id-formats
[NameIDType/EMAIL NameIDType/TRANSIENT NameIDType/PERSISTENT NameIDType/UNSPECIFIED NameIDType/X509_SUBJECT])

(def ^:private cert-uses
[UsageType/SIGNING UsageType/ENCRYPTION])

(defn metadata [{:keys [app-name acs-url slo-url sp-cert
requests-signed
want-assertions-signed]
^Boolean requests-signed
^Boolean want-assertions-signed]
:or {want-assertions-signed true
requests-signed true}}]
(let [encoded-cert (some-> ^java.security.cert.X509Certificate sp-cert
.getEncoded
encode/encode-base64
encode/bytes->str)]
(coerce/->xml-string
[:md:EntityDescriptor {:xmlns:md "urn:oasis:names:tc:SAML:2.0:metadata"
:ID (str/replace acs-url #"[:/]" "_")
:entityID app-name}
[:md:SPSSODescriptor {:AuthnRequestsSigned (str requests-signed)
:WantAssertionsSigned (str want-assertions-signed)
:protocolSupportEnumeration "urn:oasis:names:tc:SAML:2.0:protocol"}
(when encoded-cert
[:md:KeyDescriptor {:use "signing"}
[:ds:KeyInfo {:xmlns:ds "http://www.w3.org/2000/09/xmldsig#"}
[:ds:X509Data
[:ds:X509Certificate encoded-cert]]]])
(when encoded-cert
[:md:KeyDescriptor {:use "encryption"}
[:ds:KeyInfo {:xmlns:ds "http://www.w3.org/2000/09/xmldsig#"}
[:ds:X509Data
[:ds:X509Certificate encoded-cert]]]])
(when slo-url
[:md:SingleLogoutService {:Binding "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" :Location slo-url}])
[:md:NameIDFormat "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"]
[:md:NameIDFormat "urn:oasis:names:tc:SAML:2.0:nameid-format:transient"]
[:md:NameIDFormat "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"]
[:md:NameIDFormat "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"]
[:md:NameIDFormat "urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName"]
[:md:AssertionConsumerService {:Binding "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
:Location acs-url
:index "0"
:isDefault "true"}]]])))
(let [entity-descriptor (doto (.buildObject (EntityDescriptorBuilder.))
(.setID (str/replace acs-url #"[:/]" "_"))
(.setEntityID app-name))
sp-sso-descriptor (doto (.buildObject (SPSSODescriptorBuilder.))
(.setAuthnRequestsSigned requests-signed)
(.setWantAssertionsSigned want-assertions-signed)
(.addSupportedProtocol SAMLConstants/SAML20P_NS))]

(.. sp-sso-descriptor
(getAssertionConsumerServices)
(add (doto (.buildObject (AssertionConsumerServiceBuilder.))
(.setIndex (Integer. 0))
(.setIsDefault true)
(.setLocation acs-url)
(.setBinding SAMLConstants/SAML2_POST_BINDING_URI))))
(doseq [name-id-format name-id-formats]
(.. sp-sso-descriptor
(getNameIDFormats)
(add (doto (.buildObject (NameIDFormatBuilder.))
(.setURI name-id-format)))))
(when sp-cert
(let [key-info-generator (.newInstance (doto (X509KeyInfoGeneratorFactory.)
(.setEmitEntityCertificate true)))]
(doseq [cert-use cert-uses]
(.. sp-sso-descriptor
(getKeyDescriptors)
(add (doto (.buildObject (KeyDescriptorBuilder.))
(.setUse cert-use)
(.setKeyInfo (.generate key-info-generator sp-cert))))))))
(when slo-url
(.. sp-sso-descriptor
(getSingleLogoutServices)
(add (doto (.buildObject (SingleLogoutServiceBuilder.))
(.setBinding SAMLConstants/SAML2_POST_BINDING_URI)
(.setLocation slo-url)))))

(.. entity-descriptor
(getRoleDescriptors)
(add sp-sso-descriptor))
(coerce/->xml-string (XMLObjectSupport/marshall entity-descriptor))))
27 changes: 0 additions & 27 deletions test/saml20_clj/crypto_test.clj
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
(ns saml20-clj.crypto-test
(:require [clojure.test :refer :all]
[saml20-clj.coerce :as coerce]
[saml20-clj.crypto :as crypto]
[saml20-clj.test :as test]))

Expand All @@ -13,32 +12,6 @@
#"Signature does not match credential"
(crypto/assert-signature-valid-when-present response test/idp-cert))))))

(deftest sign-request-test-bad-params
(testing "Signature should throw errors with bad params"
(let [signed (coerce/->Element (coerce/->xml-string
[:samlp:AuthnRequest
{:xmlns:samlp "urn:oasis:names:tc:SAML:2.0:protocol"
:ID 1234
:Version "2.0"
:IssueInstant 1234
:ProtocolBinding "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
:ProviderName "name"
:IsPassive false
:Destination "url"
:AssertionConsumerServiceURL "url"}
[:saml:Issuer
{:xmlns:saml "urn:oasis:names:tc:SAML:2.0:assertion"}
"issuer"]]))]
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"No matching signature algorithm"
(crypto/sign signed test/sp-private-key :signature-algorithm [:rsa :crazy])))

(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"matching canonicalization algorithm"
(crypto/sign signed test/sp-private-key :canonicalization-algorithm [:bad]))))))

(deftest has-private-key-test
(testing "has private key"
(is (= true (crypto/has-private-key? {:filename test/keystore-filename
Expand Down
6 changes: 2 additions & 4 deletions test/saml20_clj/encode_decode_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
(deftest bytes->str-test
(testing "Testing string to stream and stream to string transformations."
(is (= test-string
(encode-decode/bytes->str (with-open [is (java.io.ByteArrayInputStream. (encode-decode/str->bytes test-string))]
(encode-decode/bytes->str (with-open [is (ByteArrayInputStream. (encode-decode/str->bytes test-string))]
(IOUtils/toByteArray is))))))
(testing "make sure we can encode string -> bytes -> hex"
(is (= "41424358595a"
Expand All @@ -24,9 +24,7 @@
(encode-decode/base64->str "QUJDREVG")))))

(deftest base-64-deflate-inflate-test
(testing "make sure conversion to/from base 64 w/ DEFLATE compression works as expected"
(is (= "c3RydnF1AwA="
(encode-decode/str->deflate->base64 "ABCDEF")))
(testing "make sure conversion from base 64 w/ DEFLATE compression works as expected"
(is (= "ABCDEF"
(encode-decode/base64->inflate->str "c3RydnF1AwA="))))

Expand Down
18 changes: 18 additions & 0 deletions test/saml20_clj/sp/metadata_test.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
(ns saml20-clj.sp.metadata-test
(:require [clojure.test :as t]
[saml20-clj.coerce :as coerce]
[saml20-clj.sp.metadata :as sut]
[saml20-clj.test :as test]))

(t/deftest metadata-generation
(t/testing "generates metadata with keyinfo"
(t/is (= test/metadata-with-key-info
(saml20-clj.sp.metadata/metadata {:app-name "metabase"
:acs-url "http://acs.example.com"
:slo-url "http://slo.example.com"
:sp-cert (coerce/->Credential test/sp-cert)}))))
(t/testing "generates metadata with-out keyinfo"
(t/is (= test/metadata-without-key-info
(saml20-clj.sp.metadata/metadata {:app-name "metabase"
:acs-url "http://acs.example.com"
:slo-url "http://slo.example.com"})))))
5 changes: 5 additions & 0 deletions test/saml20_clj/test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@
:when v]
[k true]))))

;; Metadata tests

(def metadata-with-key-info (sample-file "metadata-with-keyinfo.xml"))
(def metadata-without-key-info (sample-file "metadata-without-keyinfo.xml"))

;;
;; Confirmation Data
;;
Expand Down
45 changes: 45 additions & 0 deletions test/saml20_clj/test/metadata-with-keyinfo.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?><md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" ID="http___acs.example.com" entityID="metabase">
<md:SPSSODescriptor AuthnRequestsSigned="true" WantAssertionsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>MIICZjCCAc+gAwIBAgIBADANBgkqhkiG9w0BAQ0FADBQMQswCQYDVQQGEwJ1czETMBEGA1UECAwK
Q2FsaWZvcm5pYTETMBEGA1UECgwKRXhhbXBsZSBTUDEXMBUGA1UEAwwOc3AuZXhhbXBsZS5jb20w
HhcNMjAwOTIzMTc0MzA2WhcNMzAwOTIxMTc0MzA2WjBQMQswCQYDVQQGEwJ1czETMBEGA1UECAwK
Q2FsaWZvcm5pYTETMBEGA1UECgwKRXhhbXBsZSBTUDEXMBUGA1UEAwwOc3AuZXhhbXBsZS5jb20w
gZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAMCOR6lM1raadHr3MnDU7ydGHUmMhZ5ZImwSHcxY
rY6/F3TW+S6CPMuAfHJsNQZ57nG4wUhNCbfXdumfVxzoPMzD7oivKKVxeMK6HaUuGsGg9OK4ON++
EVxomWdmPyJdHpiUaGveGU0BQgzI7aqNibncPYPxJgK9DZEIfDjp05lDAgMBAAGjUDBOMB0GA1Ud
DgQWBBStKfCHxILkLbv2tAEK54+Wn/xF+zAfBgNVHSMEGDAWgBStKfCHxILkLbv2tAEK54+Wn/xF
+zAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBDQUAA4GBAIRA7mJdPPmTWc3wsPLDv+nMeR0nr5a6
r8dZU5lOTqGfC43YvJ1NEysO3AB6YuiG1KKXERxtlISyYvU9wNrna2IPDU0njcU/a3dEBqa32lD3
GxfUvbpzIcZovBYqQ7Jhfa86GvNKxRoyUEExVqyHh6i44S4NCJvr8IdnRilYBksl</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:KeyDescriptor use="encryption">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>MIICZjCCAc+gAwIBAgIBADANBgkqhkiG9w0BAQ0FADBQMQswCQYDVQQGEwJ1czETMBEGA1UECAwK
Q2FsaWZvcm5pYTETMBEGA1UECgwKRXhhbXBsZSBTUDEXMBUGA1UEAwwOc3AuZXhhbXBsZS5jb20w
HhcNMjAwOTIzMTc0MzA2WhcNMzAwOTIxMTc0MzA2WjBQMQswCQYDVQQGEwJ1czETMBEGA1UECAwK
Q2FsaWZvcm5pYTETMBEGA1UECgwKRXhhbXBsZSBTUDEXMBUGA1UEAwwOc3AuZXhhbXBsZS5jb20w
gZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAMCOR6lM1raadHr3MnDU7ydGHUmMhZ5ZImwSHcxY
rY6/F3TW+S6CPMuAfHJsNQZ57nG4wUhNCbfXdumfVxzoPMzD7oivKKVxeMK6HaUuGsGg9OK4ON++
EVxomWdmPyJdHpiUaGveGU0BQgzI7aqNibncPYPxJgK9DZEIfDjp05lDAgMBAAGjUDBOMB0GA1Ud
DgQWBBStKfCHxILkLbv2tAEK54+Wn/xF+zAfBgNVHSMEGDAWgBStKfCHxILkLbv2tAEK54+Wn/xF
+zAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBDQUAA4GBAIRA7mJdPPmTWc3wsPLDv+nMeR0nr5a6
r8dZU5lOTqGfC43YvJ1NEysO3AB6YuiG1KKXERxtlISyYvU9wNrna2IPDU0njcU/a3dEBqa32lD3
GxfUvbpzIcZovBYqQ7Jhfa86GvNKxRoyUEExVqyHh6i44S4NCJvr8IdnRilYBksl</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://slo.example.com"/>
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
<md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</md:NameIDFormat>
<md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</md:NameIDFormat>
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat>
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName</md:NameIDFormat>
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://acs.example.com" index="0" isDefault="true"/>
</md:SPSSODescriptor>
</md:EntityDescriptor>
Loading

0 comments on commit 1e4f26d

Please sign in to comment.