The ceremony by which someone becomes a subject, and nothing else.
It issues a challenge against an identifier, redeems that challenge at most once, and hands back a subject. It establishes the session that carries the subject, and it can end that subject's access everywhere. That is the whole of it.
It is a library you call, not a framework that calls you, and it does not know
web-base exists. Its dependency is ring/ring-core — a handler, a request map, a
session map and a store protocol, which every Clojure web application already speaks —
plus Integrant, which only the one optional namespace that ships its key ever loads.
1 · It authenticates; it never authorises. Whether a subject may do a thing is never this module's judgement. It arrives from the host and is obeyed.
2 · The membership test. Would a bicycle rental and a clinic's appointment book need this, unchanged? Both need someone to prove they control an address and stay logged in. Neither has a comunidad, a cargo or a junta. Anything that fails the test belongs in the host.
SPEC.md says what to build and why every boundary sits where it sits.
;; deps.edn — from Clojars
dev.arkaitz/auth-base {:mvn/version "0.2.0"}
;; or straight from git, to track a commit
dev.arkaitz/auth-base {:git/url "https://github.com/arkaitz-dev/auth-base"
:git/sha "<commit>"}
The badge at the top is the version actually published.
ring/ring-core and Integrant are the only things that reach your classpath. Not
reitit, not web-base, not a template engine, not a database driver. Integrant is there
for the optional key below and costs two jars; if you wire by hand, you call ceremony
yourself and load neither. A test resolves the real classpath a consumer of this library
gets and refuses any jar that has not been decided by name, with its reason.
issue!, redeem!, revoke!. The method — magic links by email —
is implementation one and not the contract: a password, a passkey or a single-use
code fit the same three.401 with WWW-Authenticate, for a host mounting it behind an API.What it does not do: authorise, send mail, persist, render, or know your domain.
(require '[dev.arkaitz.auth-base :as auth])
(def ceremony
(auth/ceremony
{:store (auth/in-memory-store {:subjects {"ada@example.test" {:id 1}}})
:deliver! (fn [identifier link] (mail/send! identifier link))
:link {:base-url "https://example.test" :redeem-path "/entrar"}
:ttl-ms (* 15 60 1000)
:bootstrap ["root@example.test"]}))
(def auth-routes
(auth/routes ceremony
{:view views/login ; the one view you write
:login-path "/entrar"
:logout-path "/salir"
:after-login "/"
:rate-limit {:limit 5 :window-ms (* 15 60 1000)}}))
:view is called with the request and one of four states — {}, {:sent? true},
{:spent? true}, {:limited? true} — and returns whatever your renderer accepts as a
:body: Hiccup under web-base, a string under plain Ring. That is the whole of what
this module knows about pages. {:limited? true} is the form refused by the rate
limit, answered with status 429: give it a sentence, or the person sees the ordinary
form and no reason.
Then (auth/subject-fn ceremony) is your request → subject-or-nil, and it is also
where revocation takes effect.
Your stack must have parsed the body — ring.middleware.params/wrap-params — because
the POST reads :form-params. CSRF is yours too: the token lives in the session,
which belongs to your stack, so your login view emits the field.
web-base knows that there is a subject and
never how it came to be one; it receives a function. auth-base is what sits on the
other side of that function. Neither depends on the other — you hold both, and your
deps.edn is where they meet:
{:deps {org.clojure/clojure {:mvn/version "1.12.5"}
dev.arkaitz/web-base {:mvn/version "0.3.0"} ; the web foundation
dev.arkaitz/auth-base {:mvn/version "0.2.0"}}} ; the ceremony
web-base brings reitit-ring, hiccup, ring-jetty-adapter, tools.logging, tempura,
ring-anti-forgery and integrant. auth-base brings ring-core, which web-base already
had, and integrant, which it already had too. Nothing is duplicated and neither library
can see the other.
Optional, and used rather than imposed. dev.arkaitz.auth-base.integrant ships one
key, :dev.arkaitz.auth-base/ceremony, and requiring that namespace is what installs it:
(require '[dev.arkaitz.auth-base.integrant]) ; installs the key
{:my/auth-config {:store #ig/ref :my/store
:deliver! send-the-link!
:link {:base-url "https://host" :redeem-path "/entrar"}}
:dev.arkaitz.auth-base/ceremony #ig/ref :my/auth-config}
Two of the ceremony's entries are functions and one is a protocol implementation, and
functions do not live in EDN — so you build the map in a key of your own and refer to
it, exactly as web-base's handler key is fed. routes and handlers stay ordinary
function calls: a ceremony without routes is a host mounting its own handlers, while
routes without a ceremony cannot exist, and a second key would hand you a router
opinion this module does not have.
In development the link's origin and the server's port are one fact, and it is easy to move one without the other: every link then points at a door nobody is standing at. auth-base cannot take a ref to web-base's server key — the server serves the handler, the handler closes over the ceremony, so that ref would be a cycle. Give the fact a key of your own and let both read it:
(defmethod ig/init-key :my/port [_ port] port) ; Integrant needs a method even for a value
(defmethod ig/init-key :my/auth-config [_ {:keys [port store]}]
{:store store
:deliver! send-the-link!
:link {:base-url (str "http://localhost:" port) :redeem-path "/entrar"}})
{:my/port 3000
:my/auth-config {:port #ig/ref :my/port :store #ig/ref :my/store}
:dev.arkaitz.auth-base/ceremony #ig/ref :my/auth-config
:dev.arkaitz.web-base/server {:handler #ig/ref :dev.arkaitz.web-base/handler
:port #ig/ref :my/port}}
In production the public origin is usually a different fact — a proxy's name — and belongs in configuration of its own; the pattern is for the case where they coincide.
There is no halt-key!, and the absence is deliberate: a ceremony owns no socket,
no pool and no thread. It closes over your store, whose lifetime is yours.
Then the wiring:
(require '[dev.arkaitz.web-base :as wb])
(wb/handler
{:routes [["" {:wb/layouts [views/shell]}
(into auth-routes my-routes)]]
:subject-fn (auth/subject-fn ceremony)
:login-path "/entrar"
:session {:key (env "SESSION_KEY")}})
Five lines, and demo/ is that application, running:
AUTH_DEMO_SESSION_KEY=$(openssl rand -base64 16) clojure -M:demo
It prints the link it would have emailed, so you can walk the whole ceremony in a browser with nothing installed.
Four things worth knowing, all of them proved by the demo's tests:
auth/routes returns a flat vector. SPEC §3 shows
(into auth-routes my-routes), and that is right — but a host that wants its own
shell on the login page nests the lot under a parent carrying :wb/layouts, as
above. That is reitit's composition; neither module has to agree to it.(security/csrf-field request) inside the login
view. web-base refuses the POST without it, before this module ever runs.:wb/gate wb/subject-present? on a private route. This
module never decides whether a subject may see a page.auth/establish sets Ring's :recreate metadata, which
is exactly what wb/session/rotate wraps. Do not call both.A host that has never heard of web-base mounts (auth/handlers ceremony opts) under
its own router — or under none, as harness/ does with a case over the URI.
(auth/issue! ceremony identifier) ; => nil, always, whatever the address is
(auth/redeem! ceremony token) ; => a subject, or nil
(auth/revoke! ceremony subject) ; => nil, and every session of theirs dies
issue! returns nothing on purpose, and never asks the store whether the address
is known. That is not an optimisation of the anti-enumeration rule, it is the whole of
it: there is no branch to time, because the question is only asked at redemption, when
the answer is already in the hands of whoever holds the secret. A return value that
distinguished the two would put the enumeration back one layer up.
The identifier must be a string. wrap-params gives you nil for a form field that
was absent and a vector for one that was sent twice, and both would otherwise be stored
as a challenge, handed to your deliver!, and — once you configure :on-unknown —
offered to you as somebody to create an account for. issue! refuses them. That is a
fact about the type, asks the store nothing, and leaves the rule above intact.
A delivery failure is not an authentication failure. If your deliver! throws, the
caller is told nothing — telling them would tell them something about the address — the
challenge still stands, and the failure is printed to *err* where an operator sees it.
Ring's session store is keyed by session id and cannot enumerate a subject's sessions, by design, because the same protocol has to describe a signed cookie. So revocation does not live in the store: it lives on the subject.
The store keeps a generation per subject; the session carries the generation it was
born with; subject-fn compares them on every request. revoke! moves the number on
and every session of that subject stops yielding a subject at its next request — in
this browser and in any other, with any store, including the cookie.
Its cost is one store read per request, which you may cache, and its bound is that
revocation takes effect on the next request rather than instantly.
(auth/wrap-revoked handler ceremony) is optional and additionally throws the dead
cookie away.
(require '[dev.arkaitz.auth-base.store :as store])
(defprotocol Store
(put-challenge! [store token identifier expires-at])
(take-challenge! [store token]) ; => {:ab/identifier id :ab/expires-at ms}, or nil
(subject-for [store identifier])
(generation [store subject])
(bump-generation! [store subject]))
take-challenge! must be atomic. "Redeemed at most once" is the whole security of
a secret that travels by email, and an implementation that reads and then deletes has a
window in which two readers both see the row. A test that does not run two callers
concurrently has not tested it. The one that ships uses a single swap-vals!.
It returns the row and not the identifier alone because the port carries no clock: expiry is the ceremony's policy. It consumes an expired challenge too, so a spent link cannot be retried.
subject-for must not create. An account comes into being by your act, never as a side
effect of somebody typing an address — and :on-unknown is where you perform that
act: the ceremony asks it at redemption, once the token has vouched for the address,
and nowhere else. What it returns must be = to what subject-for answers for that
identifier afterwards and to what you pass revoke!. Return the row you just wrote,
not the row plus a flag saying it was new: the session freezes this value and re-reads
its revocation generation on every request, so a value the store will never answer with
is a session no revocation can end.
dev.arkaitz.auth-base.jdbc is that port over a relational database, for a host that
wants one — plus the account half every host wrote for itself. It is optional: it
needs next.jdbc, which this library does not declare, so a host that keeps its own
store never loads it (tested with next.jdbc 1.3.1048, on H2 and SQLite).
(require '[dev.arkaitz.auth-base.jdbc :as auth-jdbc])
(auth-jdbc/check! ds) ; at boot: the three tables are as this version reads them
(auth/ceremony {:store (auth-jdbc/store ds)
:on-unknown #(auth-jdbc/register! ds %)
…})
(auth-jdbc/identifier-for ds subject) ; the address an account belongs to
(auth-jdbc/reclaim-expired! ds (System/currentTimeMillis))
ds is a javax.sql.DataSource you opened — from db-base, (:datasource db) — and
never a handle or a map, which is refused by name. It runs no migration: copy these
three statements, auth-jdbc/ddl, into your own, whole, and let check! catch a copy
that lost a table or a column (it reads names, not keys — the keys are what make
registration and revocation exact):
CREATE TABLE account (subject VARCHAR(36) NOT NULL PRIMARY KEY, identifier VARCHAR(320) NOT NULL UNIQUE, created_at BIGINT NOT NULL);
CREATE TABLE account_generation (subject VARCHAR(36) NOT NULL PRIMARY KEY, generation BIGINT NOT NULL);
CREATE TABLE login_challenge (token VARCHAR(43) NOT NULL PRIMARY KEY, identifier VARCHAR(320) NOT NULL, expires_at BIGINT NOT NULL);
Every statement is portable: take-challenge! reads and then deletes, and the delete's
count decides who redeemed the link; a revocation moves its generation by
compare-and-set. register! stores the identifier as given — pass it through
auth/normalise when it did not come from the ceremony — and is not for use inside a
transaction you opened. An address longer than 320 characters is refused by the engine
— SQLite, which ignores declared widths, excepted — and reaches your error handling as
the engine's exception.
The identities that exist before any data does are listed by address, as data you pass in — never as a file this module goes looking for, because a library that knows a file name can look for it, and then the directory a process started from decides who is an administrator.
On redemption, the absence of a record is the signal: no record, consult the list,
and if listed they enter with no row created anywhere. Not "the table is empty",
which works once for the first administrator and never again. Such a subject is
{:ab/identifier "…" :ab/bootstrap? true} — it says what it is, so an audit trail has
something to name when there is no local identity at all.
auth/ceremony — every other key is refused, naming itself:
| key | meaning |
|---|---|
:store | an implementation of the port (required) |
:deliver! | (fn [identifier link]) (required) |
:link | {:base-url "https://host" :redeem-path "/entrar"} (required) |
:ttl-ms | how long a challenge lives (default 15 minutes) |
:clock | (fn []) → epoch milliseconds (default the system clock) |
:bootstrap | identifiers that hold no record and may still enter |
:normalise | (fn [identifier]) → canonical form (default trim + lower-case); (auth/normalise ceremony id) applies it, for an address the host stores itself |
:on-unknown | (fn [identifier]) → a subject, or nil. How you answer a redemption by somebody you have no record of. Absent, there is no answer and the redemption fails |
auth/handlers and auth/routes — likewise:
| key | meaning |
|---|---|
:view | (fn [request state]) → a :body (required) |
:login-path | where the form lives (required) |
:logout-path | where the logout POST goes (default /logout) |
:after-login | where a redeemed link lands (default /) |
:after-logout | where a logout lands (default :login-path) |
:field | the form field holding the identifier (default identifier) |
:rate-limit | {:limit n :window-ms n}, a (fn [key] boolean), or absent — see below for what a refusal answers |
The rate limit is keyed by :remote-addr — the source, never the address. Counting
per address would answer differently for one somebody had just asked about, and would
let anyone spend a known user's allowance and lock them out of their own login. Behind
a proxy that is the proxy's address unless your stack is told to trust
X-Forwarded-For.
A refused request is a 429 with Cache-Control: no-store whose body is your :view
in its {:limited? true} state — the page the person was on, and a reason — and not an
empty body a browser replaces with its own error page. Under the map it also
carries Retry-After: the whole seconds until that source's window reopens, rounded
up, so a client that waits exactly that long gets in. Under your own
(fn [key] boolean) it carries none — your function says whether, not when, and a
number made up here would send an obedient client straight back into the refusal.
clojure -M:harness [port] # ring only, no framework, HTML written by hand
clojure -M:demo [port] # the same ceremony wired into web-base
Both print the link they would have emailed. harness/ is the acceptance test of SPEC
§13 — if it needs anything auth-base does not provide, the seam is in the wrong
place — and it names web-base nowhere, which one of its tests asserts by reading its
own ns form. demo/ is the integration probe, and it is where the five lines above
come from.
Two defects in this module's own surface were found by the harness rather than by any test, and both are recorded in SPEC §17: a middleware whose arguments were the wrong way round returned nil from every request without throwing, and a configuration key passed to the wrong function was silently ignored. The second is why every key is now refused by name.
clojure -M:test # the whole suite, both proofs included
clojure -M:test -n <namespace> # one namespace (several -n allowed)
clojure -T:build jar # target/auth-base-0.2.0.jar
clojure -T:build install # into ~/.m2
CLOJARS_USERNAME=… CLOJARS_PASSWORD=<deploy token> clojure -T:build deploy
Every test was written under a contract that named the invariant before the body, and
watched go red by a named mutation of the code it covers — 90-odd mutants, all killed.
The store, the ceremony, the session and the handlers went through an adversarial
review panel of mixed models, and the panel's own remediations through a second one.
The rules are in CLAUDE.md.
MIT. See LICENSE.
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 |