This is a Clojure and ClojureScript implementation of RelMeAuth, which lets people sign in as their own website through an account they already have elsewhere. The site links to the account with rel=me, the user signs in to the account with OAuth, and the account links back to the site. It works with GitHub, Mastodon and other servers that offer Mastodon's API.
The same code runs on the JVM and in Node.js. Since it needs the client secrets of your app, use it on a server rather than in a browser.
This library was spun out of indieblog, the software behind simon.grays.blog. There, the owner signs in to the blog's own IndieAuth server this way, and readers sign in to comment. Like indieblog, it was developed with assistance from frontier LLMs.
It requires Clojure 1.11+ and Java 11+. For the latest release, add it
from Clojars to the
:deps in your deps.edn:
dk.simongray/relmeauth-clj {:mvn/version "0.2.0"}
For changes that aren't released yet, use the SHA of the latest commit on
master instead:
dk.simongray/relmeauth-clj
{:git/url "https://github.com/simongray/relmeauth-clj"
:git/sha "…"}
For ClojureScript, shadow-cljs only reads Git dependencies from
deps.edn, so also set :deps true in your shadow-cljs.edn. The
functions that fetch return promises in ClojureScript.
For GitHub, register an OAuth app with your redirect URL as its callback URL. For Mastodon, there's nothing to register by hand, since each server gets a registration of your app when a user first signs in there, but you need a store for the registrations. Put the GitHub app's client ID and secret, the store and the redirect URL in the options:
(require '[dk.simongray.relmeauth :as relmeauth]
'[dk.simongray.wary-fetch.secret :as secret])
(def opts
{:redirect-uri "https://simon.grays.blog/sign-in/callback"
:github {:client-id "Ov23liAbCdEfGhIjKlMn"
:client-secret (secret/hide (System/getenv "GITHUB_SECRET"))}
:mastodon {:registrations (relmeauth/memory-store)
:client-name "simon.grays.blog"}})
Only accounts at the providers in the options are offered. A store is a
map of two functions, so one for a file or a database takes a few lines,
and the docstring of relmeauth/memory-store describes them. Keep the
registrations where a restart doesn't lose them, or each restart leads to
new registrations at every server.
Use discover! to find the accounts that the user's site links to with
rel=me:
(def discovery
(relmeauth/discover! "simon.grays.blog" opts))
;; => {:me "https://simon.grays.blog/"
;; :urls ["https://simon.grays.blog/"]
;; :accounts [{:provider :github
;; :username "simongray"
;; :url "https://github.com/simongray"}
;; {:provider :mastodon
;; :instance "https://indieweb.social"
;; :username "simongray"
;; :url "https://indieweb.social/@simongray"}]}
If you've fetched the page already, e.g. to find its IndieAuth server,
give the response to discovery for the same map. The accounts come in
the order of the site's rel=me links. When there's more than one, let the
user choose. Then send the user's browser to the
provider at the URL of authorization-request!, and keep the session
that it returns until the browser comes back, e.g. in your session store:
(def request
(relmeauth/authorization-request! discovery
(first (:accounts discovery))
opts))
;; => {:url "https://github.com/login/oauth/authorize?response_type=code&client_id=…"
;; :session {:me "https://simon.grays.blog/" :state "…" :code-verifier "…" …}}
For an account on a Mastodon server, authorization-request! also
registers your app there if the store has no registration yet. When the
browser comes back to the redirect URL, give redeem! the query
parameters and the session:
(relmeauth/redeem! (:session request) query-params opts)
;; => {:me "https://simon.grays.blog/"
;; :account {:provider :github
;; :username "simongray"
;; :url "https://github.com/simongray"}}
It checks the state, redeems the code for an
access token, and uses the token to
read the account that signed in. That must be the account that the site
names, and it must link back to the site. The token isn't kept. A
failure at any step gives a map of the :error, with its :type and
:reason, e.g. ::relmeauth/no-link-back. Use each session only once.
Anyone who can put a rel=me link on the user's page can sign in as the user. So if the page shows what others write, e.g. comments, remove rel=me from their links, as html-pieces does when it sanitizes.
To keep no session on the server, add a :secret to the options, a
string of 32 bytes or more, e.g. from indieauth-clj's indieauth/token.
The session's :cookie then carries the session, signed, so put it in a
cookie,
e.g. one marked HttpOnly and SameSite=Lax. At the redirect URL,
state-session gets the session back from that cookie and the state, so
only the browser that started a sign-in can finish it:
(relmeauth/state-session secret (get query-params "state") cookie)
Any EDN that you give as :data in the options comes back in the
session, e.g. the page to return to. The state stays short whatever the
session holds, since providers limit and log URLs, but a session too
large for a cookie throws rather than being dropped by the browser.
An IndieAuth server made with
indieauth-clj calls an
:authorize function of yours to sign in its user. With this library,
the site's owner can sign in through their accounts instead of with a
password, and the request of the IndieAuth client travels along as
:data:
(require '[dk.simongray.indieauth.server :as server]
'[dk.simongray.wary-fetch.async :as async])
(defn authorize
"Send the browser of the `ring-request` to the owner's first account,
with the IndieAuth `request` in the session's cookie."
[ring-request request]
(async/then
(relmeauth/discover! "https://simon.grays.blog/" opts)
(fn [{:keys [accounts] :as discovery}]
(async/then
(relmeauth/authorization-request! discovery (first accounts)
(assoc opts :data (server/request-token request server-opts)))
(fn [{:keys [url session]}]
{:status 302
:headers {"Location" url
"Set-Cookie" (str "signin=" (:cookie session)
"; Path=/; HttpOnly; Secure; SameSite=Lax")}})))))
At the redirect URL, once redeem! returns the owner's URL as the :me,
show the consent page for the request in the session's :data:
(server/consent (server/token-request (:data session) server-opts)
(:me signed-in)
server-opts)
:send of your own..cljc and depends on
wary-fetch for requests,
html-pieces for pages,
microformats-clj for
their rel=me links and
indieauth-clj for the
URLs that a user can sign in as.clojure -X:test # the tests on the JVM
npm install # once, for the Node tests
clojure -M:cljs compile test # the tests in Node
clojure -M:cljs compile browser-test # for a browser, from target/browser-test
The tests reproduce GitHub and a Mastodon server with Ring handlers in memory, and on the JVM, one test also signs a user in over HTTP on localhost. To sign in at the real providers by hand, follow doc/real-providers.md.
The relmeauth-clj project is licensed under the MIT licence.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |