Skip to content

Commit

Permalink
Adds SP-initiated LogoutRequests (#75)
Browse files Browse the repository at this point in the history
* Add LogoutRequest ability

* java-time -> java-time.api

* add test + cleanup

* fix linter issues + redundant let

* appease linter on import format

* add another test

* adding a docstring to make-logout-request-xml

* Update readme + Bump copywrite
  • Loading branch information
escherize authored Feb 23, 2024
1 parent a1461ad commit a21e757
Show file tree
Hide file tree
Showing 11 changed files with 263 additions and 79 deletions.
46 changes: 42 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
[![Clojars Project](https://clojars.org/metabase/saml20-clj/latest-version.svg)](http://clojars.org/metabase/saml20-clj)


This is a SAML 2.0 Clojure library for SSO acting as a fairly thin wrapper around the Java libraries [OpenSAML
This is a SAML 2.0 Clojure library for SSO acting as a thin wrapper around the Java libraries [OpenSAML
v4](https://wiki.shibboleth.net/confluence/display/OS30/Home) and some utility functions from [OneLogin's SAML
library](https://github.com/onelogin/java-saml) This library allows a Clojure application to act as a Service Provider
(SP).
Expand Down Expand Up @@ -64,7 +64,9 @@ implementation if you need something more sophisticated.
(def state-manager (saml/in-memory-state-manager))
```

### Requests
### Logging In (SSO)

#### Requests

Basic usage for requests to the IdP looks like:

Expand Down Expand Up @@ -96,7 +98,7 @@ The `:credential` can be used to sign the request to the IdP, and attach any pub
:password "keystore-password"
:alias "key-alias"}`: A map describing a keystore and alias used.

### Responses
#### Responses

Basic usage for responses from the IdP looks like this (assuming a Ring `request`):

Expand Down Expand Up @@ -230,6 +232,41 @@ shown below:
:address]
```

### Logging Out (SLO)

#### Requests

Basic usage for logging out is to send the client a redirect to the IdP, with a LogoutResponse SAML message. This is
done in the following manner:

```clj
(request/idp-logout-redirect-response
"Your SP Name"
"logmeout@example.com" ;; the user's email
"http://sp.example.com/demo1/metadata.php"
(encode-decode/str->base64 "http://sp.example.com/demo1/metadata.php"))
"my_random_id_42") ;; req-id is optional, and will get created for you.

```

Some clients will prefer that you send them the `SAMLRequest` as a query parameter, and they will handle the redirect, for that purpose you can use the `logout-redirect-location` function, which will include the `RelayState` and `SAMLRequest` as query parameters.

```clj
(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")})

;; =>
;; "http://idp.example.com/SSOService.php?SAMLRequest=fVLLbs<snip>&RelayState=aHR<snip>"
```

#### Responses

The IdP will redirect the client back to you, with a `SAMLResponse` in their query-params. You can validate this response by checking for the `SAMLResponse`'s `Status`.

## Differences from the original `saml20-clj` library

This repository is forked from [vlacs/saml20-clj](https://github.com/vlacs/saml20-clj), and at this point is more or less a complete re-write.
Expand All @@ -246,11 +283,12 @@ This repository is forked from [vlacs/saml20-clj](https://github.com/vlacs/saml2
* Reorganized code
* Removed tons of duplicate/unnecessary, untested code
* Fixed `<Assertion>` signatures not being validated
* Added Single Logout (SLO)

## License

* Copyright © 2013 VLACS <jdoane@vlacs.org>
* Copyright © 2017 Kenji Nakamura <kenji@signifier.jp>
* Copyright © 2019-2022 [Metabase, Inc.](https://metabase.com)
* Copyright © 2019-2024 [Metabase, Inc.](https://metabase.com)

Distributed under the Eclipse Public License, the same as Clojure.
5 changes: 4 additions & 1 deletion src/saml20_clj/core.clj
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@

[request
idp-redirect-response
request]
request
logout-redirect-location
idp-logout-redirect-response
make-logout-request-xml]

[response
decrypt-response
Expand Down
3 changes: 1 addition & 2 deletions src/saml20_clj/sp/metadata.clj
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,7 @@
[: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: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"]
Expand Down
126 changes: 93 additions & 33 deletions src/saml20_clj/sp/request.clj
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
(ns saml20-clj.sp.request
(:require [clojure.string :as str]
[java-time :as t]
[java-time.api :as t]
[ring.util.codec :as codec]
[saml20-clj.coerce :as coerce]
[saml20-clj.crypto :as crypto]
Expand All @@ -16,6 +16,28 @@
(and (string? s)
(not (str/blank? s))))

(defn random-request-id
"Generates a random ID for a SAML request, if none is provided."
[]
(str "id" (random-uuid)))

(defn- make-auth-xml [request-id instant sp-name idp-url acs-url issuer]
[:samlp:AuthnRequest
{:xmlns:samlp "urn:oasis:names:tc:SAML:2.0:protocol"
:ID (or request-id (random-request-id))
:Version "2.0"
:IssueInstant (format-instant instant)
:ProtocolBinding "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
:ProviderName sp-name
:IsPassive false
:Destination idp-url
:AssertionConsumerServiceURL acs-url}
[:saml:Issuer
{:xmlns:saml "urn:oasis:names:tc:SAML:2.0:assertion"}
issuer]
;;[:samlp:NameIDPolicy {:AllowCreate false :Format saml-format}]
])

(defn request
"Return XML elements that represent a SAML 2.0 auth request."
^org.w3c.dom.Element [{:keys [ ;; e.g. something like a UUID. Random UUID will be used if no other ID is provided
Expand All @@ -33,53 +55,91 @@
;; If present, we can sign the request
credential
instant]
:or {request-id (str "id" (java.util.UUID/randomUUID))
instant (t/instant)}}]
:or {instant (t/instant)}}]
(assert (non-blank-string? acs-url) "acs-url is required")
(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 (coerce/->Element (coerce/->xml-string
[:samlp:AuthnRequest
{:xmlns:samlp "urn:oasis:names:tc:SAML:2.0:protocol"
:ID request-id
:Version "2.0"
:IssueInstant (format-instant instant)
:ProtocolBinding "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
:ProviderName sp-name
:IsPassive false
:Destination idp-url
:AssertionConsumerServiceURL acs-url}
[:saml:Issuer
{:xmlns:saml "urn:oasis:names:tc:SAML:2.0:assertion"}
issuer]
;;[:samlp:NameIDPolicy {:AllowCreate false :Format saml-format}]
]))]
(let [request (coerce/->Element
(coerce/->xml-string
(make-auth-xml request-id instant sp-name idp-url acs-url issuer)))]
(when state-manager
(state/record-request! state-manager (.getAttribute request "ID")))
(if-not credential
request
(or (crypto/sign request credential)
(throw (ex-info "Failed to sign request" {:request request}))))))

(defn uri-query-str
^String [clean-hash]
(codec/form-encode clean-hash))
(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)))

(defn idp-redirect-response
"Return Ring response for HTTP 302 redirect."
[saml-request idp-url relay-state]
{:pre [(some? saml-request) (string? idp-url) (string? relay-state)]}
(let [saml-request-str (if (string? saml-request)
saml-request
(coerce/->xml-string saml-request))
url (str idp-url
(if (str/includes? idp-url "?")
"&"
"?")
(let [saml-request-str (encode-decode/str->deflate->base64 saml-request-str)]
(uri-query-str
{:SAMLRequest saml-request-str, :RelayState relay-state})))]
{:pre [(some? saml-request)
(string? idp-url)
(string? relay-state)]}
(let [saml-request-str (cond-> saml-request
(not (string? saml-request)) coerce/->xml-string)
saml-request-str (encode-decode/str->deflate->base64 saml-request-str)
url (add-query-params idp-url {:SAMLRequest saml-request-str
:RelayState relay-state})]
{:status 302 ; found
:headers {"Location" url}
:body ""}))

;; 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: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}
[:saml:Issuer issuer]
[:saml:NameID {:Format "urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress"} user-email]
[:samlp:SessionIndex "SessionIndex_From_Authentication_Assertion"]])

(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]}]
(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}))

(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 ""})))
4 changes: 2 additions & 2 deletions src/saml20_clj/sp/response.clj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
(ns saml20-clj.sp.response
"Code for parsing the XML response (as a String)from the IdP to an OpenSAML `Response`, and for basic operations like
validating the signature and reading assertions."
(:require [java-time :as t]
(:require [java-time.api :as t]
[saml20-clj.coerce :as coerce]
[saml20-clj.crypto :as crypto]
[saml20-clj.state :as state]
Expand Down Expand Up @@ -313,7 +313,7 @@
Check the javadoc of OpenSAML at:
https://build.shibboleth.net/nexus/service/local/repositories/releases/archive/org/opensaml/opensaml/2.5.3/opensaml-2.5.3-javadoc.jar/!/index.html"
https://web.archive.org/web/20150421234900/https://build.shibboleth.net/nexus/service/local/repositories/releases/archive/org/opensaml/opensaml/2.5.3/opensaml-2.5.3-javadoc.jar/!/index.html"
[response]
(when-let [response (coerce/->Response response)]
(let [status (.. response getStatus getStatusCode getValue)]
Expand Down
2 changes: 1 addition & 1 deletion src/saml20_clj/state.clj
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
(ns saml20-clj.state
(:require [java-time :as t]
(:require [java-time.api :as t]
[pretty.core :as pretty]))

(defprotocol StateManager
Expand Down
8 changes: 4 additions & 4 deletions test/saml20_clj/coerce_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,10 @@ c7tL1QjbfAUHAQYwmHkWgPP+T2wAv0pOt36GgMCM
(testing "public only"
(is (= idp-fingerprints
(x509-credential-fingerprints (coerce/->Credential {:filename test/keystore-filename
:password test/keystore-password
:alias "idp"})))))
:password test/keystore-password
:alias "idp"})))))
(testing "public + private"
(is (= sp-fingerprints
(x509-credential-fingerprints (coerce/->Credential {:filename test/keystore-filename
:password test/keystore-password
:alias "sp"}))))))))
:password test/keystore-password
:alias "sp"}))))))))
44 changes: 22 additions & 22 deletions test/saml20_clj/crypto_test.clj
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
(ns saml20-clj.crypto-test
(:require [clojure.test :refer :all]
[java-time :as t]
[java-time.api :as t]
[saml20-clj.coerce :as coerce]
[saml20-clj.crypto :as crypto]
[saml20-clj.sp.request :as request]
Expand Down Expand Up @@ -59,28 +59,28 @@
(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])))
[: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]))))))
(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"
Expand Down
Loading

0 comments on commit a21e757

Please sign in to comment.