Skip to content

Commit

Permalink
added files from Glossematics
Browse files Browse the repository at this point in the history
  • Loading branch information
simongray committed Mar 9, 2023
1 parent 892deba commit b48fb45
Show file tree
Hide file tree
Showing 9 changed files with 1,105 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.idea
*.iml
182 changes: 182 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
Pedestal SP
===========
Enhance your [Pedestal](https://github.com/pedestal/pedestal) web service with [SAML](https://en.wikipedia.org/wiki/Security_Assertion_Markup_Language) 2.0 routes to turn to it a valid [Service Provider](https://en.wikipedia.org/wiki/Service_provider_(SAML)) - or _SP_ for short.

* [Why use this?](#why-use-this)
* [Setup](#setup)
* [Authentication and authorisation](#authentication-and-authorisation)
* [SAML authentication endpoints](#saml-authentication-endpoints)
* [Other endpoints](#other-endpoints)

> _This project was made using [quephird/saml-test](https://github.com/quephird/saml-test) as a reference while applying the much more recent, actively developed fork of the [saml20-clj](https://github.com/metabase/saml20-clj) library by Metabase._
Why use this?
-------------
In academia - as well as in the corporate world - SAML is a very popular way to implement [Single Sign-On](https://en.wikipedia.org/wiki/Single_sign-on) (SSO) for web services.<sup>[](#saml-overview)</sup>

To log in to a web service, the so-called Service Provider (SP) must delegate user authentication to one or more Identity Providers (IdP). A common way to do this is by setting up [Shibboleth-sp](https://wiki.shibboleth.net/confluence/display/SP3/Home) as a separate web service and then integrating that with your own web service through a fairly involved setup involving XML files, using a Java web server as the middle-man.

Personally, I like keep things more tightly integrated and simpler to understand. You should consider using **Pedestal SP** if you need users to authenticate via a SAML IdP and think integrating with Shibboleth-sp sounds too complex.

> _<a name="saml-overview"><sup>†</sup></a> Take a look at [this video](https://www.youtube.com/watch?v=SvppXbpv-5k) to get a quick overview of how SAML works._
Who uses this?
--------------
We do! [Glossematics.dk](https://glossematics) depends on Pedestal SP to enable logging in through SAML.

Be aware...
-----------
To make this library work with an IdP you will most likely also need to make sure your content is served as HTTPS. You can use one of the servers supported by Pedestal for this, but personally I prefer using something like nginx or caddy to handle this aspect of modern web development.

Furthermore, this library depends on [/metabase/saml20-clj](https://github.com/metabase/saml20-clj) which wraps a more recent version of OpenSAML than is currently available on Maven Central. You will likely need to add the Shibboleth repository, e.g. using `deps.edn`:

```clojure
{...
:mvn/repos {"opensaml" {:url "https://build.shibboleth.net/nexus/content/repositories/releases/"}}
...}
```

Setup
-----
**Pedestal SP** is divided into the following namespaces:

* `dk.cst.pedestal.sp.routes`: prepackaged SAML routes to add to your Pedestal web service.
* `dk.cst.pedestal.sp.conf`: config map creation + relevant Clojure Spec definitions.
* `dk.cst.pedestal.sp.auth`: functions for setting/checking authentication and authorisation.
* `dk.cst.pedestal.sp.interceptors`: interceptors for SAML authentication and observability.
* `dk.cst.pedestal.sp.example`: an example web service using **Pedestal SP**.

Like Pedestal itself, **Pedestal SP** is configured using a config map containing just a few required keys, mostly related to encryption. Before consumption, the base config is expanded using `sp.conf/init` and passed to the `sp.routes/all` function. The same config map should be reused when defining auth interceptor chains using `sp.ic/chain`.

Here's an example using a minimal config:

````clojure
(require '[dk.cst.pedestal.sp.routes :as sp.routes]
'[dk.cst.pedestal.sp.conf :as sp.conf])

(def base-conf
{:sp-url "https://localhost:4433"
:idp-url "https://localhost:7000"
:idp-cert (slurp "/path/to/idp-public-cert.pem")
:credential {:alias "mylocalsp"
:filename "/path/to/keystore.jks"
:password (System/getenv "KEYSTORE_PASS")}})

(def conf
(sp.conf/init base-conf))

;; This constructor function will provide a ready-made set of SAML routes.
;; You may also define the routes yourself too using the provided interceptors.
(def routes
(sp.routes/all conf))
````

### Mock IdP
While developing your SAML SP, your probably want a mock IdP to develop up against. I followed the instructions at [quephird/saml-test](
https://github.com/quephird/saml-test#getting-things-running), specifically the parts related to getting the Node-based IdP running and creating a certificate for it. Once you have generated a certificate you can set the following keys in the config map:

```clojure
:idp-url "https://localhost:7000"
:idp-cert (slurp "/path/to/idp-public-cert.pem")
```

Once you're ready to put your web service into production, it should simply be a matter of swapping the mock IdP for the real one<sup>[](#idp-caveat)</sup>.

### Keystore
Java - and by extension Clojure - applications use a [Java KeyStore](https://en.wikipedia.org/wiki/Java_KeyStore) as the main way to store and access encryption keys. It is simply a file you create using the `keytool` CLI, with some associated Java methods providing access to the certificates within.

The KeyStore provides the credentials needed to properly sign your SAML requests. You give your web service access to the keystore by providing three required keys in the `:credential` submap of your config:

```clojure
:credential {:alias "mylocalsp"
:filename "/path/to/keystore.jks"
:password (System/getenv "KEYSTORE_PASS")}
```

> _Note: make sure to add `-keyalg RSA` to the keytool command that use to create your keystore. This is expected by the underlying saml20-clj library._
### Your service
Now all that remains is defining the identity of your web service. While developing you will want to use a local URL, but obviously for a production system you will want to use the proper URL:

```clojure
:sp-url "http://localhost:8080"
```

Altogether, these 4 keys (`:idp-url`, `:idp-cert`, `:credential`, `:sp-url`) make up the required parts of the base config. The remaining keys are all optional.

> _<a name="idp-caveat"><sup>†</sup></a> Depending on what IdP you're integrating with, additional steps might need to be taken. That is beyond the scope of this little setup guide._
Authentication and authorisation
--------------------------------
SAML is meant to be a complete package for handling authentication and authorisation. **Pedestal SP** builds on this design with helpful functions in a simple to understand system based on Pedestal interceptors and the familiar Ring session middleware.

All authorisation checks in **Pedestal SP** compare the user assertions that have been provided by the IdP to some kind of restriction defined by the developer. This includes authorisation checks at the route level, as well as inline authorisation checks - the latter which can be made in both Clojure and ClojureScript.

### SAML-authenticated sessions
By default, the act of logging in via a SAML IdP is treated as successful authentication, though you can specify additional parameters that must be met through the `:validation` map in the config. The available options are explained in the `saml20-clj.sp.response/validate` function of [metabase/saml20-clj](https://github.com/metabase/saml20-clj). The authentication itself has been delegated entirely to this library.

Once authenticated, the IdP response and its assertions are stored in an in-memory Ring session store with a limited TTL. The session store and other Ring session-related parameters can be customised via the `:session` key of the config map. Refer to `ring.middleware.session/wrap-session` for the available configuration options.

### Route authorisation
Two route-level authorisation helper functions - `chain` and `permit-request?` - can be found in the `dk.cst.pedestal.sp.interceptors` namespace (aliased as `sp.ic` in the examples below). The `sp.ic/chain` function can be used to build an interceptor chain to restrict a route, e.g.

```clojure
["/some/route" (conj (sp.ic/chain conf :authenticated) `protected-page)]
```

The above snippet defines a route that can only be accessed by an authenticated user. More stringent authorisation requirements can be specified too; these dig more deeply into the IdP assertions about the user.

When generating dynamic content for the user, it quite often becomes necessary to know ahead of time if the user is authorised to access a specific resource. To solve this common issue, **Pedestal SP** also comes with the `sp.ic/permit-request?` function which can be used to check authorisation status within an `sp.ic/chain`:

```clojure
(when (sp.ic/permit-request? ctx "/some/route")
[:p "You may visit " [:a {:href "/some/route"} "this route"]])
```

By dynamically looking up the route in the router (provided via the Interceptor context) in order to trial requests ahead of time, the code defining the authorisation restrictions is completely decoupled from the code depending on these restrictions.

### Inline authorisation
Two macros are provided `dk.cst.pedestal.sp.auth` to define authorisation restrictions embedded in both Clojure and ClojureScript code: `if-permit` and `only-permit`. The first macro branches like a regular `if`-form, while the second one will throw an exception when the user assertions do not meet the given restriction.

These inline authorisation checks can be used to e.g. build out a single-route backend API endpoint or fine-tune the HTML UI generated by a Single Page Application (SPA) to reflect the authorisation level of the user. Note that in the second case, it becomes to necessary to figure out a way to transport the user assertions from the backend to the frontend.

Whether you use route-level authorisation or inline authorisation checks (backend or frontend) depends on the type of application you're developing. In a typical SPA you will probably need all three at some point.

SAML authentication endpoints
-----------------------------
It is helpful to understand the flow of an SP-initiated SAML login and how it is represented in **Pedestal SP**.
Typically, the login flow will start in one of two ways:

* The user clicks a button or hyperlink labeled "log in" or something similar.
* The user attempts to access an off-limits resource and is either nudged towards or directly redirected to a SAML login flow.

> _Note: The code in the `dk.cst.pedestal.sp.example` namespace illustrates how to make a basic SAML-enabled login page. It makes use of (or links to) all of the `/saml/...` endpoints described below._
### 302 GET `/saml/login`
The SAML login flow starts with an HTTP GET request to `/saml/login`, likely along with `?RelayState=/path/to/resource` as a query string. The RelayState will be passed around the entire SAML flow and - if present - will be used to redirect the user back to where they came from at the end of a successful login. This first SAML endpoint redirects the user to the IdP specified in the config map.

### 200 GET `<URL of IdP>`
We specify _who_ the IdP is in our config map, but we have no control over it otherwise. The IdP is where the actual login takes place. Once logged in, the IdP is supposed to redirect back to our `/saml/login` endpoint, this time using an HTTP POST request.

### 303 POST `/saml/login`
Our SP now receives signed data from the IdP which we decrypt and verify. This data contains assertions about the logged in user. Based on this information we either deny access or redirect the user to the initial resource they were trying to access, which the IdP hopefully provided in the `RelayState` query parameter. This is the end of the SAML login flow.

From here on, we use a session cookie in the browser (named `pedestal-sp` by default) to verify the user's identity. The chain of authentication interceptors can be used to gate restricted resources at different endpoints.

There is also a metadata endpoint which isn't directly invoked as part of the login flow:

### 200 GET `/saml/meta`
Service Provider metadata exposed as XML. This is SAML-related information about your web service made available to any IdP that you choose to integrate with.

Other endpoints
---------------
Apart from the standard SAML authentication endpoints, by default **Pedestal SP** also provides a few convenience endpoints:

### 204/303 POST `/saml/logout`
Making a post request to this endpoint will return HTTP status 204 and delete SAML-related information pertaining to the user from the session store of the web service. Providing a `RelayState` query parameter will result in a 303 redirect instead, treating the value of the parameter as the requested location. This behaviour supports using this endpoint both via an async API call and via regular HTML form submission.

### 200 GET `/saml/response`
Echoes back the SAML response XML received from the IdP during login - or HTTP status 403 when logged out. Serves as example usage of the `restrictions` interceptor chain.

### 200 GET `/saml/assertions`
Echoes back the user assertions contained in the SAML response received from the IdP during login - or HTTP status 403 when logged out. The assertions are returned as EDN. Serves as example usage of the `restrictions` interceptor chain.
9 changes: 9 additions & 0 deletions deps.edn
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{:mvn/repos {"opensaml" {:url "https://build.shibboleth.net/nexus/content/repositories/releases/"}}
:deps {metabase/saml20-clj {:mvn/version "2.1.0"}
io.pedestal/pedestal.service {:mvn/version "0.5.11-SNAPSHOT"}
io.pedestal/pedestal.route {:mvn/version "0.5.11-SNAPSHOT"}
io.pedestal/pedestal.jetty {:mvn/version "0.5.11-SNAPSHOT"}
org.clojure/data.json {:mvn/version "2.4.0"}
luminus/ring-ttl-session {:mvn/version "0.3.3"}
hiccup/hiccup {:mvn/version "1.0.5"}
tick/tick {:mvn/version "0.5.0"}}}
104 changes: 104 additions & 0 deletions src/dk/cst/pedestal/sp/auth.cljc
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
(ns dk.cst.pedestal.sp.auth
"Create inline authorisation logic using SAML assertions. The `if-permit` and
`only-permit` macros can be used from both Clojure and ClojureScript.
For route-level authorisation + ahead-of-time checks from within interceptors,
use the `permit-request?` function from `dk.cst.pedestal.sp.interceptors`."
(:require [clojure.data :as data]
#?(:clj [ring.util.codec :as codec])
[clojure.string :as str])
#?(:cljs (:require-macros [dk.cst.pedestal.sp.auth])))

(defn- safe-base64
"Replace certain characters in base64 with ones that won't be URL-encoded."
[s]
(str/replace s #"/|\+|=" {"/" "_", "+" "-", "=" "."}))

(defn- unsafe-base64
"Undo the transformation of 'safe-base64'."
[s]
(str/replace s #"_|-|\." {"_" "/", "-" "+", "." "="}))

(defn safe-encode
"Encode a `url` as base64 with certain problematic characters replaced.
This encoding should survive any URL encoding/decoding scheme it may be passed
through, ensuring that the input `url` survives until decoded."
[url]
(safe-base64 #?(:clj (codec/base64-encode (.getBytes (codec/url-encode url)))
:cljs (js/btoa (js/encodeURIComponent url)))))

(defn safe-decode
"Decode a URL encoded as `base64` via 'safe-encode'."
[base64]
#?(:clj (-> base64 unsafe-base64 codec/base64-decode slurp codec/url-decode)
:cljs (-> base64 unsafe-base64 js/atob js/decodeURIComponent)))

;; TODO: misplaced...? put in another CLJC file?
(defn saml-path
"Get the specified `saml-type` in `paths` with an encoded `RelayState`."
[paths saml-type & [RelayState]]
(str (get paths saml-type) (when RelayState
(str "?RelayState=" (safe-encode RelayState)))))

(defn submap?
"Is `m` a submap of `parent`?"
[m parent]
(nil? (first (data/diff m parent))))

(defn request->assertions
[request]
(get-in request [:session :saml :assertions]))

(defn condition->auth-test
"Return a function to test an assertions map based on a given `condition`:
:authenticated - requires authentication to access.
:all - can be accessed by anyone, no restrictions apply.
:none - no access by anyone under any circumstances.
map - allow access when the assertions contain this submap.
fn - takes assertions as input and returns true if accessible."
[condition]
(cond
(keyword? condition) (case condition
:authenticated some?
:all (constantly true)
:none (constantly false))
(map? condition) #(submap? condition %)
(fn? condition) condition))

(defn auth-override
"Create an auth test override from the `assertions` map.
During development, the assertions map may contain a :condition key defining
an alternative test used to override the conditions of a production system."
[assertions]
(condition->auth-test (:condition assertions)))

(defmacro if-permit
"Checks that `assertions` satisfies `condition`. When true, returns the
first clause of `body`; else returns the second clause."
[[assertions condition] & body]
`(if ((or (auth-override ~assertions)
(condition->auth-test ~condition)) ~assertions)
~@body))

(defmacro only-permit
"Checks that `assertions` satisfies `condition`. If true, returns `body`;
else throws an exception."
[[assertions condition] & body]
`(if ((or (auth-override ~assertions)
(condition->auth-test ~condition)) ~assertions)
(do ~@body)
(throw (ex-info "Unsatisfied condition" {::condition ~condition}))))

#?(:clj
(defn enforce-condition
"Fail fast if the `request` assertions do not meet a `condition`."
[request condition]
(only-permit [(request->assertions request) condition])))

(comment
(let [url "https://glossematics.dk/app/search?limit=10&offset=0&correspondent=%23np56%2C%23np145"]
(= url (safe-decode (safe-encode url))))
#_.)
Loading

0 comments on commit b48fb45

Please sign in to comment.