Public entry point of auth-base: the ceremony by which someone becomes a subject, and nothing else (SPEC §1).
It is a library you call. It never calls the host back except through the
functions the host hands it, it knows nothing about web-base or any other
framework, and its only dependency is ring/ring-core — a handler, a request
map, a session map and a store protocol, which every Clojure web application
in the world already speaks.
A host wires it in one place:
(def ceremony
(auth/ceremony {:store (auth/in-memory-store {:subjects {"ada@example.test" :ada}})
:deliver! (fn [identifier link] (send-mail! identifier link))
:link {:base-url "https://example.test" :redeem-path "/entrar"}}))
(def auth-routes
(auth/routes ceremony {:view views/login
:login-path "/entrar"}))
and under web-base, that is the whole of the integration:
(wb/handler {:routes (into auth-routes my-routes)
:subject-fn (auth/subject-fn ceremony)
:login-path "/entrar"
:session {:key (env "SESSION_KEY")}})
A host that has never heard of web-base mounts (auth/handlers ceremony opts)
under its own router instead, and reads the subject with the same
subject-fn.
What is here and what is not. It authenticates; it never authorises — whether a subject may do a thing arrives as the host's own predicate and is obeyed (SPEC §2). It owns the credential as a unit of its own and has no idea what a person is, which is what lets an administrator exist before any data does. It composes what must be delivered and hands it over; it does not send mail. It receives a store; it does not open a database.
To implement the store port against a real database, require
dev.arkaitz.auth-base.store and extend its Store protocol. Read its
docstring first: take-challenge! must be atomic, and that is the whole
security of a secret that travels by email.
Public entry point of auth-base: the ceremony by which someone becomes a
subject, and nothing else (SPEC §1).
It is a **library you call**. It never calls the host back except through the
functions the host hands it, it knows nothing about web-base or any other
framework, and its only dependency is `ring/ring-core` — a handler, a request
map, a session map and a store protocol, which every Clojure web application
in the world already speaks.
A host wires it in one place:
(def ceremony
(auth/ceremony {:store (auth/in-memory-store {:subjects {"ada@example.test" :ada}})
:deliver! (fn [identifier link] (send-mail! identifier link))
:link {:base-url "https://example.test" :redeem-path "/entrar"}}))
(def auth-routes
(auth/routes ceremony {:view views/login
:login-path "/entrar"}))
and under web-base, that is the whole of the integration:
(wb/handler {:routes (into auth-routes my-routes)
:subject-fn (auth/subject-fn ceremony)
:login-path "/entrar"
:session {:key (env "SESSION_KEY")}})
A host that has never heard of web-base mounts `(auth/handlers ceremony opts)`
under its own router instead, and reads the subject with the same
`subject-fn`.
**What is here and what is not.** It authenticates; it never authorises —
whether a subject may do a thing arrives as the host's own predicate and is
obeyed (SPEC §2). It owns the credential as a unit of its own and has no idea
what a person is, which is what lets an administrator exist before any data
does. It composes what must be delivered and hands it over; it does not send
mail. It receives a store; it does not open a database.
To implement the store port against a real database, require
`dev.arkaitz.auth-base.store` and extend its `Store` protocol. Read its
docstring first: `take-challenge!` must be atomic, and that is the whole
security of a secret that travels by email.The ceremony of SPEC §6, in three acts: issue a challenge against an identifier, redeem it at most once, end a subject's access everywhere. The method — magic links by email — is implementation one and not the contract; a password, a passkey or a single-use code would arrive as another implementation of these same three calls.
Two properties of the code below are load-bearing and easy to lose in an innocent refactor:
issue! never asks whether the identifier is known. Not once. That is
not an optimisation of the anti-enumeration rule (SPEC §11), 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 future edit that consults the store here to 'avoid pointless work'
reintroduces exactly the defect.
A delivery failure is not an authentication failure (SPEC §8). It cannot reach the caller, because reaching the caller means telling them something about the address; it goes to the operator instead.
The ceremony of SPEC §6, in three acts: issue a challenge against an identifier, redeem it at most once, end a subject's access everywhere. The method — magic links by email — is implementation one and not the contract; a password, a passkey or a single-use code would arrive as another implementation of these same three calls. Two properties of the code below are load-bearing and easy to lose in an innocent refactor: **`issue!` never asks whether the identifier is known.** Not once. That is not an optimisation of the anti-enumeration rule (SPEC §11), 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 future edit that consults the store here to 'avoid pointless work' reintroduces exactly the defect. **A delivery failure is not an authentication failure** (SPEC §8). It cannot reach the caller, because reaching the caller means telling them something about the address; it goes to the operator instead.
Ring handlers over the ceremony, and the same handlers as reitit route data for a host that wants them mounted rather than wired.
The host supplies one view. It is called with the request and one of three
states — {}, {:sent? true}, {:spent? true} — and returns whatever that
host's 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.
Three details are security, not ergonomics:
The redemption never renders. A link opened from a page carries its URL to
whatever that page loads next, so a token in a Referer reaches every third
party the landing page touches. The redemption answers 303 and nothing
else, always, and says Referrer-Policy: no-referrer on its own so a host
without web-base's headers is no worse off.
The token is read from the URI, not from a router's path parameters. It means a host mounts these handlers under any router or none, and it means there is no naming convention to get wrong.
The POST does the same work whatever the address is — it cannot do
otherwise, because issue! never asks whether the address is known — and
answers the same 303 either way (SPEC §11).
Ring handlers over the ceremony, and the same handlers as reitit route data
for a host that wants them mounted rather than wired.
The host supplies **one** view. It is called with the request and one of three
states — `{}`, `{:sent? true}`, `{:spent? true}` — and returns whatever that
host's 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.
Three details are security, not ergonomics:
**The redemption never renders.** A link opened from a page carries its URL to
whatever that page loads next, so a token in a `Referer` reaches every third
party the landing page touches. The redemption answers `303` and nothing
else, always, and says `Referrer-Policy: no-referrer` on its own so a host
without web-base's headers is no worse off.
**The token is read from the URI**, not from a router's path parameters. It
means a host mounts these handlers under any router or none, and it means
there is no naming convention to get wrong.
**The POST does the same work whatever the address is** — it cannot do
otherwise, because `issue!` never asks whether the address is known — and
answers the same `303` either way (SPEC §11).Rate limiting belongs to whoever authenticates (SPEC §11): web-base pushed it
out of the base explicitly. What the limit is, and whether it counts by
address or by source, SPEC §15 leaves open — so what ships here is one fixed
window over a key the caller chooses, and the seam to replace it: the
ceremony takes any (fn [key] boolean).
The bound on memory is not decoration. The keys come from unauthenticated requests, so an attacker chooses how many distinct ones exist; a map that only grows is an out-of-memory an anonymous caller can reach. At the cap the oldest window is dropped — never the newest, and never by refusing the newcomer, which would let anyone lock everybody else out by filling the table. The cost is real and is the price of the bound: a key that was being counted can be forgotten and let back in inside its own window.
Under a fixed window the oldest window is also the first to expire, so dropping the oldest already drops an expired one whenever there is any. A pass that removed the expired ones first would decide exactly the same thing every time, which is why there is not one.
Rate limiting belongs to whoever authenticates (SPEC §11): web-base pushed it out of the base explicitly. What the limit is, and whether it counts by address or by source, SPEC §15 leaves open — so what ships here is one fixed window over a key the caller chooses, and the seam to replace it: the ceremony takes any `(fn [key] boolean)`. The bound on memory is not decoration. The keys come from unauthenticated requests, so an attacker chooses how many distinct ones exist; a map that only grows is an out-of-memory an anonymous caller can reach. At the cap the oldest window is dropped — never the newest, and never by refusing the newcomer, which would let anyone lock everybody else out by filling the table. The cost is real and is the price of the bound: a key that was being counted can be forgotten and let back in inside its own window. Under a fixed window the oldest window is also the first to expire, so dropping the oldest already drops an expired one whenever there is any. A pass that removed the expired ones first would decide exactly the same thing every time, which is why there is not one.
Establishing the session that carries the subject (SPEC §9), reading it back (SPEC §3) and letting revocation reach it (SPEC §10).
Nothing here requires Ring's session middleware, or knows which store is
behind it. :recreate is Ring's own convention — ring.middleware.session
reads it off the session map's metadata, deletes the old session and
writes a new one under a fresh key — so a module that sets that metadata is
defended against session fixation under any Ring host, web-base included,
whose own session/rotate is a wrapper over the same two lines.
The mark and the session are set in one act on purpose. Marking first and assoc'ing the map later loses the mark, and loses it with no symptom at all: everything keeps working and an attacker who planted a session id before the login still holds a valid one after it.
Establishing the session that carries the subject (SPEC §9), reading it back (SPEC §3) and letting revocation reach it (SPEC §10). Nothing here requires Ring's session middleware, or knows which store is behind it. `:recreate` is Ring's own convention — `ring.middleware.session` reads it off the session map's **metadata**, deletes the old session and writes a new one under a fresh key — so a module that sets that metadata is defended against session fixation under any Ring host, web-base included, whose own `session/rotate` is a wrapper over the same two lines. The mark and the session are set in **one act** on purpose. Marking first and assoc'ing the map later loses the mark, and loses it with no symptom at all: everything keeps working and an attacker who planted a session id before the login still holds a valid one after it.
Storage is a port (SPEC §7). The module receives a store the way Ring receives a session store, and never opens a database of its own.
take-challenge! is the load-bearing one: it 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. Every implementation of this protocol has to make the read
and the removal one act, and a test that does not run the two concurrently has
not tested it.
What the port deliberately does not carry: a clock. Expiry is policy and the
ceremony owns it, so take-challenge! hands back the row it removed —
identifier and expiry — and consumes a spent challenge whether or not it had
expired. A challenge that expires is still a challenge that cannot be used
twice.
Storage is a port (SPEC §7). The module receives a store the way Ring receives a session store, and never opens a database of its own. `take-challenge!` is the load-bearing one: **it 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. Every implementation of this protocol has to make the read and the removal one act, and a test that does not run the two concurrently has not tested it. What the port deliberately does not carry: a clock. Expiry is policy and the ceremony owns it, so `take-challenge!` hands back the row it removed — identifier *and* expiry — and consumes a spent challenge whether or not it had expired. A challenge that expires is still a challenge that cannot be used twice.
The challenge token: 256 bits from a cryptographic source, spelled in the URL-safe base64 alphabet so it survives a path segment untouched.
Two things here are not decoration. The generator lives behind a delay
because a SecureRandom in a var root works perfectly on a JVM and has no
symptom there, while GraalVM's native-image bakes the instance into the
binary with its seed — every deployment of that binary would then mint the
same tokens. And well-formed? is the boundary check: an unauthenticated
caller chooses the string that reaches the store, so its length and alphabet
are settled before it gets there.
The challenge token: 256 bits from a cryptographic source, spelled in the URL-safe base64 alphabet so it survives a path segment untouched. Two things here are not decoration. The generator lives behind a `delay` because a `SecureRandom` in a var root works perfectly on a JVM and has no symptom there, while GraalVM's `native-image` bakes the instance into the binary with its seed — every deployment of that binary would then mint the same tokens. And `well-formed?` is the boundary check: an unauthenticated caller chooses the string that reaches the store, so its length and alphabet are settled before it gets there.
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 |