You might be familiar with OAuth scopes from for example the Github dialog for creating a new access token. You get to choose what the token is authorized to do: can the user manage repos? Leave reviews? Push commits? There are a ton of options. Similarly Mastodon has scopes such as “see favorites” or “post on your behalf.”

padlock on black metal fence

In an API using OAuth scopes, most of the endpoints will have well defined scope requirements. In the Mastodon example, it’s clear that the endpoint to create a new toot is not related to seeing the user’s favorites. It can be really convenient to have these scope requirements explicitly right there in the route definitions. It makes it much more declarative instead of using tons of Ring middleware functions for example to achieve something similar.

I think that the way reitit allows basically any kind of arbitrary route data makes this extremely easy. Add a :scope option to the routes and use a set to list what is needed, like #{:profile/edit}. It doesn’t have to be a set (can be just a single keyword or a map like {:profile ::edit}), but sets can be used as functions and they make checking for inclusion with some really simple too (so it’s easy to confirm if the scopes given to a token include what’s required or not).

Another neat trick is to use derivation to define a hierarchy of scopes. You can’t edit a profile you can’t see after all. Given a hierarchy like below (based on Github’s classic scopes), it’s easy to check if the token has what’s needed (or anything stronger).

(derive :enterprise/runners :enterprise/admin)
(derive :enterprise/billing :enterprise/admin)
(derive :enterprise/read :enterprise/admin)
user=> (let [auth :enterprise/read]
  (isa? :enterprise/read auth))
true
user=> (let [auth :enterprise/admin]
  (isa? :enterprise/read auth))
true
user=> (let [auth :enterprise/runners]
  (isa? :enterprise/read auth))
false

The various methods can be combined too, for example using derivation for the various “levels” of access and using those in maps, like ::read and ::write in {:repo ::read}.

A critical piece of the puzzle is the middleware that looks at the scopes required by the route (using the Match object injected into the request map at :reitit.core/match), compares it with what the user has (in a JWT, the database, whatever you use) and returns a 403 error if the token doesn’t have the required privileges. One thing to remember is that this is a check for OAuth scopes, meaning this is for seeing if a token has the privileges to do something in the first place. Seeing whether the resource involved exists at all or if the user has the authority to touch it (like on Mastodon as a user you can only delete your own posts) is a different step.