This is a Clojure and ClojureScript implementation of IndieAuth, the profile of OAuth 2.0 with which people sign in to apps as their own website, e.g. to post to it with Micropub. It covers both roles: the client, which signs a user in, and the server, which authenticates the user and issues access tokens.
The same code runs on the JVM, in Node and in the browser.
This library was spun out of indieblog, the software of simon.grays.blog, which makes the blog its own IndieAuth server and signs its commenters in with their own. 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/indieauth-clj {:mvn/version "0.1.0"}
For changes that aren't released yet, use the SHA of the latest commit on
master instead:
dk.simongray/indieauth-clj
{:git/url "https://github.com/simongray/indieauth-clj"
:git/sha "…"}
For ClojureScript, shadow-cljs only reads Git dependencies from
deps.edn, so also set :deps true in your shadow-cljs.edn. A function
that fetches gives a promise there. In a browser, a page can only be read
when its server allows other origins with CORS, which most profile pages
don't, so a client usually runs on a server.
With discover!, find the server of the URL that the user typed:
(require '[dk.simongray.indieauth :as indieauth])
(def discovery
(indieauth/discover! "simon.grays.blog"))
;; => {:me "https://simon.grays.blog/"
;; :urls ["https://simon.grays.blog/"]
;; :metadata-url "https://simon.grays.blog/auth/metadata"
;; :metadata {…}
;; :issuer "https://simon.grays.blog/"
;; :authorization-endpoint "https://simon.grays.blog/auth"
;; :token-endpoint "https://simon.grays.blog/auth/token"}
Then send the user's browser to the :url that authorization-request
gives, and keep its :session where the browser's next request finds
it, e.g. in your session store:
(def request
(indieauth/authorization-request discovery
{:client-id "https://app.example.net/"
:redirect-uri "https://app.example.net/callback"
:scope "profile create"}))
;; => {:url "https://simon.grays.blog/auth?response_type=code&client_id=…"
;; :session {:state "…" :code-verifier "…" …}}
Without a scope, you get the user's profile URL alone. The browser comes
back to the redirect URL with query parameters, which redeem! takes
with the session:
(indieauth/redeem! (:session request) query-params)
;; => {:me "https://simon.grays.blog/"
;; :access_token "…"
;; :token_type "Bearer"
;; :scope "profile create"
;; :expires_in 86400
;; :refresh_token "…"
;; :profile {:name "Simon Gray" :url "https://simon.grays.blog/"}}
It checks the state and the issuer, redeems the code, and confirms that
the page of the :me names the same server, as section 5.4 asks. A
failure at any step gives a map of the :error, with its :type and
:reason. A session redeems one code. Keep it with the answer if you'll
use the token later, since refresh!, userinfo! and revoke! take it
too.
When the access token expires, after its :expires_in seconds,
refresh! exchanges the :refresh_token for a new access token. Its
answer has a new refresh token too, which replaces the old one. With the
session and a token, userinfo! reads the user's profile information
again, and revoke! revokes the token when the user signs out.
Serve your client metadata at the client identifier with
client-metadata-handler, so that servers can show your app's name and
logo. List there too any redirect URL that isn't on the client
identifier's host.
If you'd rather not keep sessions in your app, give
authorization-request a :secret. The state then carries the session,
signed, and the session's :nonce goes in a cookie, e.g. one with
HttpOnly and SameSite=Lax. At the redirect URL, state-session gets
the session back from the state and the cookie's nonce, so that only the
browser that started a sign-in can finish it.
A server consists of its Ring handlers, two stores and a function of
yours, :authorize, which signs the user in as you like and shows them
the consent page:
(require '[dk.simongray.indieauth.server :as server])
(declare opts)
(defn authorize
"The consent page of the authorization `request` for the user of the
`ring-request`, or the sign-in page when nobody is signed in."
[ring-request request]
(if-let [me (signed-in-user ring-request)]
(consent-page (server/consent request me opts))
(sign-in-page (server/request-token request opts))))
(def opts
{:issuer "https://simon.grays.blog/"
:metadata-url "https://simon.grays.blog/auth/metadata"
:authorization-endpoint "https://simon.grays.blog/auth"
:token-endpoint "https://simon.grays.blog/auth/token"
:revocation-endpoint "https://simon.grays.blog/auth/revoke"
:userinfo-endpoint "https://simon.grays.blog/auth/userinfo"
:scopes-supported ["profile" "email" "create" "update" "delete"]
:secret (indieauth/token)
:codes (server/memory-store)
:tokens (server/memory-store)
:authorize authorize})
Serve each endpoint with its handler, each made from opts:
server/handler for the authorization endpoint, and token-handler,
revocation-handler, userinfo-handler and metadata-handler for the
others. Name them on the user's pages with the links of server/links
or server/link-header. The :secret is a text of 32 bytes or more.
Make it once, e.g. with indieauth/token, and keep it in your
configuration, since a new one at each start voids the sign-ins under
way.
The handler checks the client's request before authorize gets it, and
request-token carries it through your sign-in pages. The consent page
names the client and lists the scopes that consent gives, and its form
posts back the :token, which is signed for that user alone. When the
user says yes, send the browser on to the :redirect that approve!
gives:
(server/approve! token {:me "https://simon.grays.blog/" :scopes ["create"]} opts)
;; => {:redirect "https://app.example.net/callback?code=…&state=…&iss=…"}
The code works once and for a minute, and the token handler exchanges
it for an access token, which lasts a day, and a refresh token, which
lasts 30 days unused. When the user says no, send the browser to the URL
of server/denial. A store is a map of a few functions, so one for a
database takes a few lines, and the docstring of server/memory-store
lists them. The options that have defaults, e.g. those lifetimes, are in
server/default-options.
A resource server in the same process, e.g. a Micropub endpoint, checks
the token of a request with access! and the server's opts:
(require '[dk.simongray.indieauth.resource :as resource])
(resource/access! ring-request ["create"] opts)
;; => {:me "https://simon.grays.blog/"
;; :client-id "https://app.example.net/"
;; :scope "create"
;; :issued-at #inst "2026-10-11T12:00:00Z"
;; :expires #inst "2026-10-12T12:00:00Z"
;; :grant "…"}
When the token is missing, unknown or lacks the scope, it gives the
:error and the Ring :response to answer with.
A resource server in another process asks the server's introspection
endpoint instead. Add the :resource-tokens that resource servers may
ask with to the server's opts, and serve
server/introspection-handler. Then give access! the
:introspection-endpoint and one of those tokens as its
:resource-token, in place of the :tokens.
indieauth/default-options and
server/default-options list their defaults, which follow the spec.:access_token. Every function
that sends takes a :send of your own..cljc and depends on
wary-fetch for requests,
bytes and JSON,
html-pieces for pages and
microformats-clj for
h-cards and h-apps.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
On the JVM, a test also signs a user in at a server of this library over HTTP on localhost. doc/indieauth-rocks.md says which scenarios of indieauth.rocks the tests cover, and how to run the suite itself.
The indieauth-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 |