Skip to content

Commit

Permalink
fix: generate signed SLO requests
Browse files Browse the repository at this point in the history
Updates our SLO request generation system using the same principles as
the previous changes to the AuthnRequest generation.

This does drop the make-logout-request-xml function from the public API.
  • Loading branch information
edpaget committed Feb 21, 2025
1 parent b1e935e commit 200ec3b
Show file tree
Hide file tree
Showing 3 changed files with 126 additions and 158 deletions.
3 changes: 1 addition & 2 deletions src/saml20_clj/core.clj
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,7 @@
idp-redirect-response
request
logout-redirect-location
idp-logout-redirect-response
make-logout-request-xml]
idp-logout-redirect-response]

[response
decrypt-response
Expand Down
155 changes: 70 additions & 85 deletions src/saml20_clj/sp/request.clj
Original file line number Diff line number Diff line change
@@ -1,25 +1,18 @@
(ns saml20-clj.sp.request
(ns saml20-clj.sp.request
(:require [clojure.string :as str]
[java-time.api :as t]
[ring.util.codec :as codec]
[saml20-clj.coerce :as coerce]
[saml20-clj.encode-decode :as encode-decode]
[saml20-clj.state :as state])
(:import org.opensaml.messaging.context.MessageContext
[org.opensaml.saml.common.messaging.context SAMLBindingContext SAMLEndpointContext SAMLPeerEntityContext]
org.opensaml.saml.common.xml.SAMLConstants
org.opensaml.saml.saml2.binding.encoding.impl.HTTPRedirectDeflateEncoder
[org.opensaml.saml.saml2.core AuthnRequest NameIDType]
[org.opensaml.saml.saml2.core.impl AuthnRequestBuilder IssuerBuilder NameIDPolicyBuilder]
[org.opensaml.saml.saml2.core AuthnRequest LogoutRequest NameIDType]
[org.opensaml.saml.saml2.core.impl AuthnRequestBuilder IssuerBuilder LogoutRequestBuilder NameIDBuilder NameIDPolicyBuilder]
org.opensaml.saml.saml2.metadata.impl.SingleSignOnServiceBuilder
org.opensaml.xmlsec.context.SecurityParametersContext
org.opensaml.xmlsec.SignatureSigningParameters))

(defn- format-instant
"Converts a date-time to a SAML 2.0 time string."
[instant]
(t/format (t/format "YYYY-MM-dd'T'HH:mm:ss'Z'" (t/offset-date-time instant (t/zone-offset 0)))))

(defn- non-blank-string? [s]
(and (string? s)
(not (str/blank? s))))
Expand All @@ -31,6 +24,28 @@

(def ^:private -sig-alg "http://www.w3.org/2000/09/xmldsig#rsa-sha1")

(defn- setup-message-context
[message credential sig-alg idp-url]
(let [msgctx (doto (MessageContext.) (.setMessage message))]
(when credential
(let [decoded-credential (try
(coerce/->Credential credential)
(catch Throwable _
(coerce/->Credential (coerce/->PrivateKey credential))))
^SecurityParametersContext security-context (.getSubcontext msgctx SecurityParametersContext true)]
(.setSignatureSigningParameters security-context
(doto (SignatureSigningParameters.)
(.setSignatureAlgorithm sig-alg)
(.setSigningCredential decoded-credential)))))

(let [^SAMLPeerEntityContext peer-context (.getSubcontext msgctx SAMLPeerEntityContext true)
^SAMLEndpointContext endpoint-context (.getSubcontext peer-context SAMLEndpointContext true)]
(.setEndpoint endpoint-context
(doto (.buildObject (SingleSignOnServiceBuilder.))
(.setBinding SAMLConstants/SAML2_REDIRECT_BINDING_URI)
(.setLocation idp-url))))
msgctx))

(defn build-authn-obj
^AuthnRequest [request-id instant sp-name idp-url acs-url issuer]
(doto (.buildObject (AuthnRequestBuilder.)
Expand Down Expand Up @@ -85,36 +100,10 @@
(assert (non-blank-string? idp-url) "idp-url is required")
(assert (non-blank-string? sp-name) "sp-name is required")
(assert (non-blank-string? issuer) "issuer is required")
(let [request (build-authn-obj request-id instant sp-name idp-url acs-url issuer)
msgctx (doto (MessageContext.) (.setMessage request))]
(let [request (build-authn-obj request-id instant sp-name idp-url acs-url issuer)]
(when state-manager
(state/record-request! state-manager (.getID request)))
(when credential
(let [decoded-credential (try
(coerce/->Credential credential)
(catch Throwable _
(coerce/->Credential (coerce/->PrivateKey credential))))
^SecurityParametersContext security-context (.getSubcontext msgctx SecurityParametersContext true)]
(.setSignatureSigningParameters security-context
(doto (SignatureSigningParameters.)
(.setSignatureAlgorithm sig-alg)
(.setSigningCredential decoded-credential)))))

(let [^SAMLPeerEntityContext peer-context (.getSubcontext msgctx SAMLPeerEntityContext true)
^SAMLEndpointContext endpoint-context (.getSubcontext peer-context SAMLEndpointContext true)]
(.setEndpoint endpoint-context
(doto (.buildObject (SingleSignOnServiceBuilder.))
(.setBinding SAMLConstants/SAML2_REDIRECT_BINDING_URI)
(.setLocation idp-url))))
msgctx))

(defn- add-query-params
"Add query parameters to a URL.
(add-query-params \"http://example.com\" {:a \"b\" :c \"d\"}
;; => \"http://example.com?a=b&c=d\""
[url params]
(str url (if (str/includes? url "?") "&" "?") (codec/form-encode params)))
(setup-message-context request credential sig-alg idp-url)))

(defn- map-making-servlet
"Implements a minimum HttpServletResponse for HTTPRedirectDeflateEncoder"
Expand All @@ -130,12 +119,8 @@
(get [_] servlet-wrapper))]
[wrapper-supplier #(deref response)]))

(defn idp-redirect-response
"Return Ring response for HTTP 302 redirect."
(defn- redirect-response
[^MessageContext saml-request relay-state]
{:pre [(some? saml-request)
(string? relay-state)]}

;; implmenets HttpServletResponse interface and provides a function for retrieving the request
;; as a ring map
(let [[servlet ->ring-request] (map-making-servlet)
Expand All @@ -152,54 +137,54 @@
(.encode))
(->ring-request)))

;; I wanted to call this make-request-xml, but it gets exported in core.clj, which
;; warrants the request prefix
(defn make-logout-request-xml
"Generates a SAML 2.0 logout request, as a hiccupey datastructure."
[& {:keys [request-id instant idp-url issuer user-email]
:or {instant (format-instant (t/instant))}}]
(assert (non-blank-string? idp-url) "idp-url is required")
(assert (non-blank-string? issuer) "issuer is required")
(assert (non-blank-string? user-email) "user-email is required")
[:samlp:LogoutRequest {:xmlns "urn:oasis:names:tc:SAML:2.0:protocol"
:xmlns:samlp "urn:oasis:names:tc:SAML:2.0:protocol"
:xmlns:saml "urn:oasis:names:tc:SAML:2.0:assertion"
:Version "2.0"
:ID (or request-id (str "id" (random-uuid)))
:IssueInstant instant
:Destination idp-url}
[:Issuer {:xmlns "urn:oasis:names:tc:SAML:2.0:assertion"} issuer]
[:NameID {:xmlns "urn:oasis:names:tc:SAML:2.0:assertion"
:Format "urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress"} user-email]])
(defn idp-redirect-response
"Return Ring response for HTTP 302 redirect."
[^MessageContext saml-request relay-state]
{:pre [(some? saml-request)
(string? relay-state)]}
(redirect-response saml-request relay-state))

(defn logout-redirect-location
"This returns a url that you'd want to redirect a client to. Either using
`ring/redirect` with a 302 status code or passing it to a client in a post body
to have them redirect to."
[& {:keys [issuer user-email idp-url relay-state request-id]}]
(defn- build-logout-obj
^LogoutRequest [issuer user-email idp-url instant request-id]
(assert (non-blank-string? idp-url) "idp-url is required")
(assert (non-blank-string? user-email) "user-email is required")
(assert (non-blank-string? issuer) "issuer is required")
(assert (non-blank-string? relay-state) "relay-state is required")
(add-query-params idp-url {:SAMLRequest (encode-decode/str->deflate->base64
(coerce/->xml-string (make-logout-request-xml
:idp-url idp-url
:request-id request-id
:issuer issuer
:user-email user-email)))
:RelayState relay-state}))
(assert (non-blank-string? user-email) "user-email is required")
(doto (.buildObject (LogoutRequestBuilder.)
SAMLConstants/SAML20P_NS
"LogoutRequest"
"samlp")
(.setID request-id)
(.setIssueInstant instant)
(.setDestination idp-url)
(.setIssuer (doto (.buildObject (IssuerBuilder.)
SAMLConstants/SAML20_NS
"Issuer"
"saml")
(.setValue issuer)))
(.setNameID (doto (.buildObject (NameIDBuilder.)
SAMLConstants/SAML20_NS
"NameID"
"saml")
(.setValue user-email))))
)

(defn idp-logout-redirect-response
"Return Ring response for HTTP 302 redirect."
([issuer user-email idp-url relay-state]
(idp-logout-redirect-response issuer user-email idp-url relay-state (random-request-id)))
([issuer user-email idp-url relay-state request-id]
(let [url (logout-redirect-location
:idp-url idp-url
:user-email user-email
:issuer issuer
:relay-state relay-state
:request-id request-id)]
{:status 302 ; found
:headers {"Location" url}
:body ""})))
(idp-logout-redirect-response {:issuer issuer
:user-email user-email
:idp-url idp-url
:relay-state relay-state
:request-id request-id}))
([{:keys [request-id instant idp-url issuer user-email credential relay-state sig-alg]
:or {instant (t/instant)
request-id (random-request-id)
sig-alg -sig-alg}}]
(let [logout-request (build-logout-obj issuer user-email idp-url instant request-id)]
(redirect-response (setup-message-context logout-request credential sig-alg idp-url) relay-state))))

(defn logout-redirect-location
[& args]
(get-in (idp-logout-redirect-response args) [:headers "location"]))
126 changes: 55 additions & 71 deletions test/saml20_clj/sp/request_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@
[saml20-clj.coerce :as coerce]
[saml20-clj.encode-decode :as encode-decode]
[saml20-clj.sp.request :as request]
[saml20-clj.test :as test])
(:import java.net.URI))
[saml20-clj.test :as test]))

(def target-uri "http://sp.example.com/demo1/index.php?acs")

Expand Down Expand Up @@ -137,77 +136,62 @@
(re-pattern (format "%s is required" (name k)))
(request/request request))))))))))

(deftest logout-request-test
(let [logout-xml (t/with-clock (t/mock-clock (t/instant "2020-09-24T22:51:00.000Z"))
(request/make-logout-request-xml
{:request-id "ONELOGIN_109707f0030a5d00620c9d9df97f627afe9dcc24"
:user-email "user@example.com"
:idp-url "http://idp.example.com/SSOService.php"
:issuer "http://sp.example.com/demo1/metadata.php"}))]
(is (= [:samlp:LogoutRequest
{:xmlns "urn:oasis:names:tc:SAML:2.0:protocol",
:xmlns:samlp "urn:oasis:names:tc:SAML:2.0:protocol",
:xmlns:saml "urn:oasis:names:tc:SAML:2.0:assertion",
:Version "2.0",
:ID "ONELOGIN_109707f0030a5d00620c9d9df97f627afe9dcc24",
:IssueInstant "2020-09-24T22:51:00Z",
:Destination "http://idp.example.com/SSOService.php"}
[:Issuer
{:xmlns "urn:oasis:names:tc:SAML:2.0:assertion"}
"http://sp.example.com/demo1/metadata.php"]
[:NameID
{:xmlns "urn:oasis:names:tc:SAML:2.0:assertion",
:Format "urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress"}
"user@example.com"]]
logout-xml))))

(t/with-clock (t/mock-clock (t/instant "2020-09-24T22:51:00.000Z"))
(request/logout-redirect-location
{:issuer "http://sp.example.com/demo1/metadata.php"
:user-email "user@example.com"
:idp-url "http://idp.example.com/SSOService.php"
:request-id "ONELOGIN_109707f0030a5d00620c9d9df97f627afe9dcc24"
:relay-state (encode-decode/str->base64 "http://sp.example.com/demo1/metadata.php")}))

(defn parse-query-params [url]
(let [query (-> (URI. url) .getQuery)
pairs (str/split query #"\&")]
(reduce (fn [params pair]
(let [[key val] (str/split pair #"=" 2)]
(assoc params key val)))
{}
pairs)))

(deftest logout-location-test
(t/with-clock (t/mock-clock (t/instant "2020-09-24T22:51:00.000Z"))
(let [req-id "ONELOGIN_109707f0030a5d00620c9d9df97f627afe9dcc24"
idp-url "http://idp.example.com/SSOService.php"
user-email "user@example.com"
issuer "http://sp.example.com/demo1/metadata.php"
location
(request/logout-redirect-location
{:issuer issuer
:user-email user-email
:idp-url idp-url
:request-id req-id
:relay-state (encode-decode/str->base64 issuer)})
{:strs [SAMLRequest RelayState]} (parse-query-params location)]
(is (= (coerce/->xml-string (request/make-logout-request-xml :request-id req-id :idp-url idp-url :issuer issuer :user-email user-email))
(encode-decode/base64->inflate->str SAMLRequest))
"SAMLRequest is generated correctly")
(is (= issuer (encode-decode/base64->str RelayState))))))

(deftest idp-logout-redirect-response-test
(t/with-clock (t/mock-clock (t/instant "2020-09-24T22:51:00.000Z"))
(let [req-id "ONELOGIN_109707f0030a5d00620c9d9df97f627afe9dcc24"
idp-url "http://idp.example.com/SSOService.php"
user-email "user@example.com"
issuer "http://sp.example.com/demo1/metadata.php"
logout-url (request/logout-redirect-location
{:issuer issuer
:user-email user-email
:idp-url idp-url
:request-id req-id
:relay-state (encode-decode/str->base64 issuer)})
redirect (request/idp-logout-redirect-response issuer user-email idp-url (encode-decode/str->base64 issuer) req-id)]
(is (= logout-url (get-in redirect [:headers "Location"]))))))
issuer "http://sp.example.com/demo1/metadata.php"]
(testing "without signing"

(is (= {:status 302
:headers {"Cache-control" "no-cache, no-store"
"Pragma" "no-cache"
"location" (str
"http://idp.example.com/SSOService.php?SAMLRequest="
"nZHLTsMwEEV%2FJfK%2BycS0DbGaFKQAihRaiRQWbJBlT9pI8Y"
"PYqfr5pA%2BkwoIFO4%2Bse88czWJ5UF2wx961RmckDoEEqIWR"
"rd5m5HXzOLkly3zhuOosq8zWDP4FPwd0PhiD2rHTT0aGXjPDXe"
"uY5god84LV988VoyEw2xtvhOlIUIy5VnN%2FYu28tyyKWmlDPH"
"BlOwyFUVFdr2vs963A0O4sCcoiI%2BvVQ7V%2BKlcfMaQJJA3A"
"DfCZBJhTEKlMZZMmzZwmvMFUCkGnY8y5AUvtPNc%2BIxQoTCCd"
"0OmGUjaLGUAIAO8kePtWHzclZ1F2yvZXgn%2F7ceewPzqR%2FO"
"LkfipJVCaOFHouuedHrUV0BbpQV2NxWfyHOoyvuyvgpf1cmJ%2"
"BnX9fLvwA%3D&RelayState=aHR0cDovL3NwLmV4YW1wbGUuY2"
"9tL2RlbW8xL21ldGFkYXRhLnBocA%3D%3D")}
:body ""}
(request/idp-logout-redirect-response
{:issuer issuer
:user-email user-email
:idp-url idp-url
:relay-state (encode-decode/str->base64 issuer)
:request-id req-id}))))
(testing "with signing"
(is (= {:status 302
:headers {"Cache-control" "no-cache, no-store"
"Pragma" "no-cache"
"location" (str
"http://idp.example.com/SSOService.php?SAMLRequest="
"nZHLTsMwEEV%2FJfK%2BycS0DbGaFKQAihRaiRQWbJBlT9pI8Y"
"PYqfr5pA%2BkwoIFO4%2Bse88czWJ5UF2wx961RmckDoEEqIWR"
"rd5m5HXzOLkly3zhuOosq8zWDP4FPwd0PhiD2rHTT0aGXjPDXe"
"uY5god84LV988VoyEw2xtvhOlIUIy5VnN%2FYu28tyyKWmlDPH"
"BlOwyFUVFdr2vs963A0O4sCcoiI%2BvVQ7V%2BKlcfMaQJJA3A"
"DfCZBJhTEKlMZZMmzZwmvMFUCkGnY8y5AUvtPNc%2BIxQoTCCd"
"0OmGUjaLGUAIAO8kePtWHzclZ1F2yvZXgn%2F7ceewPzqR%2FO"
"LkfipJVCaOFHouuedHrUV0BbpQV2NxWfyHOoyvuyvgpf1cmJ%2"
"BnX9fLvwA%3D&RelayState=aHR0cDovL3NwLmV4YW1wbGUuY2"
"9tL2RlbW8xL21ldGFkYXRhLnBocA%3D%3D&SigAlg=http%3A%"
"2F%2Fwww.w3.org%2F2000%2F09%2Fxmldsig%23rsa-sha1&S"
"ignature=oChvLW5JXLhuCbD38uNk7EQv9D4TwQxfMKNKCmd1N"
"RLE205H96kC1XBz%2BcTKN5Q1vqbEO%2Fg3u5esCSeEsElEkdd"
"0PKkRq9M64RyzLJg70jeCyQYEVRjM9k6TatAX8ge4dWMieyiE7"
"5yuOCGlASPZ1nck8cKxVtDTORLc6OaZ2vM%3D")}
:body ""}
(request/idp-logout-redirect-response
{:issuer issuer
:credential test/sp-private-key
:user-email user-email
:idp-url idp-url
:relay-state (encode-decode/str->base64 issuer)
:request-id req-id})))))))

0 comments on commit 200ec3b

Please sign in to comment.