export JWT_SECRET="minimum-32-character-secret-key"
Wagoe’s user library handles the full authentication lifecycle:
user registration, login, JWT tokens, sessions, and multi-factor authentication.
export JWT_SECRET="minimum-32-character-secret-key"
The JWT secret must be at least 32 characters. Tests that exercise auth also require this variable:
JWT_SECRET="dev-secret-at-least-32-characters-long" clojure -M:test :user
curl -X POST http://localhost:3000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "password": "secret"}'
Response:
{
"authenticated": true,
"sessionToken": "...",
"jwtToken": "eyJhbG...",
"user": {"id": "...", "email": "user@example.com", "role": null}
}
POST /api/v1/sessions answers the same. A refused login is an error in the
standard shape ({"error": {"type", "message", "correlation-id"}}):
| Status | When |
|---|---|
200 with | The password was right; send it again with |
400 | The body is malformed or the email is not a valid address. |
401 | Wrong email or password. The body is the same whether or not the account exists. |
401 | Wrong |
403 | MFA is required and the user has not enrolled. |
429 | Too many failed attempts. |
Every route requires a signed-in user, by session cookie or Authorization: Bearer
JWT, unless its route data says :public true. Which permission it needs is in
Authorization.
[["/profile" {:authorization :signed-in
:get {:handler handlers/get-profile
:summary "Get current user profile"}}]
["/webhook" {:public true
:post {:handler handlers/webhook}}]]
MFA uses TOTP (Time-based One-Time Passwords, compatible with Google Authenticator / Authy).
# 1. Start MFA setup (returns a secret, QR code URL and backup codes)
curl -X POST http://localhost:3000/api/v1/auth/mfa/setup \
-H "Authorization: Bearer <token>"
# 2. Enable MFA (verify with first TOTP code)
curl -X POST http://localhost:3000/api/v1/auth/mfa/enable \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"secret": "...", "backupCodes": ["..."], "verificationCode": "123456"}'
curl -X POST http://localhost:3000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "password": "secret", "mfaCode": "123456"}'
By default a login asks for a code exactly when the user turned MFA on. To decide
per login, name a function under :auth:
:wagoe/user {:auth {:mfa-required? my.app.auth/mfa-required?}}
;; Skip MFA on the office network, require it everywhere else.
(defn mfa-required? [_user {:keys [ip-address]}]
(not (office-network? ip-address)))
It gets the user and the login context (:ip-address, :user-agent, and
:provider for an OpenID Connect sign-in) and applies to password and OpenID
Connect logins alike. Returning false waives the code; MFA stays on for the user.
Returning true for a user without MFA refuses the login: they must sign in
where MFA is not required, or ask an administrator. Two exceptions, on the web:
a user whose MFA an administrator reset, within :mfa-reset-enrolment-hours
(72) of the reset, and a user who has just registered. For them the right
password leads to setting up an authenticator at /web/login/mfa-setup, which
is audited, after which they sign in with a code. A password change voids the
setup page’s ticket. The API answers 403 mfa-enrolment-required, and so does
an OpenID Connect sign-in. The symbol is resolved at boot, and one that does not
resolve stops the boot. A function that throws fails the login.
A hook that needs one of your components, say the allowed networks kept in the
database, comes from an application module instead. Its ig-config contributes it:
(defn ig-config [_settings _ctx]
{:components {:wagoe/office-networks {:db (ig/ref :wagoe/db-context)}}
:auth {:mfa-required? (ig/ref :wagoe/office-networks)}})
The component is a function of the same two arguments, or implements
wagoe.user.ports/IMfaPolicy. Each system runs its own, so a test system and a dev
system in one JVM do not share state. Setting the hook both in config and from a
module, or from two modules, stops the boot.
Behind a reverse proxy, set :wagoe/http :trusted-proxies before keying a
hook on :ip-address; without it every login carries the proxy’s address.
|
Send the current password, or a current TOTP or backup code; a user who signs
in only through OIDC has only the code. A wrong one counts as a failed login and
locks the account like one.
curl -X POST http://localhost:3000/api/v1/auth/mfa/disable \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"code": "123456"}'
Any OpenID Connect provider (Google, Microsoft Entra, Keycloak, Auth0, …) can sign
people in to the web UI. Name each provider under :wagoe/user:
:wagoe/user
{:oidc {:base-url "https://app.example.com" ; where the provider redirects back
:providers {:google {:issuer "https://accounts.google.com"
:client-id #env GOOGLE_CLIENT_ID
:client-secret #env GOOGLE_CLIENT_SECRET
:scopes ["openid" "email" "profile"] ; the default
:label "Google" ; button text
:link-by-email? false ; the default
:trust-email? false ; the default
:auto-create? false}}}} ; the default
Register <base-url>/web/auth/oidc/<provider>/callback as the redirect URI at the
provider. The login page then shows a "Sign in with Google" button.
The flow is the authorization code flow with PKCE. The discovery document and the provider’s signing keys are fetched and cached for an hour. The id token’s signature, issuer, audience, expiry (60 seconds of clock skew allowed) and nonce are checked. The state, nonce and PKCE verifier travel in an encrypted cookie that expires after ten minutes, so any replica can finish a sign-in another one started.
Which account a sign-in belongs to:
The account already linked to the token’s issuer and subject. That link outlives an email change at the provider.
Otherwise, with :link-by-email?, the account with the token’s email, and only when
the provider marks that email verified. The link is stored from then on. An
administrator account is never linked this way; the sign-in is refused.
Otherwise, with :auto-create?, a new account with no role and no usable password. Its
rights come from Everyone and the identity provider’s groups. :default-role is
ignored.
Otherwise the sign-in is refused.
Some providers, Microsoft Entra among them, send no email_verified claim, so no email
counts as verified. :trust-email? true on such a provider treats its email claim as
verified.
Set :trust-email? only for a single-tenant issuer you control. Anyone who can
put an arbitrary address in that provider’s email claim can then sign in as the account
with that address. Multi-tenant issuer templates such as
https://login.microsoftonline.com/{tenantid}/v2.0 are refused; name one tenant.
|
A successful sign-in opens the same database session a password login opens. The same account rules apply: a deactivated or locked account cannot sign in, and MFA is asked for after the provider by the same rule as a password login (Deciding per login). Wrong codes count towards the lockout.
The cookie key derives from JWT_SECRET; set :state-secret under :oidc to use
another. The client secret is sent with HTTP Basic; set :token-endpoint-auth :post on
a provider that wants it in the form body. Leave :client-secret out for a public
client.
After signing in, everyone lands on the dashboard, the admin’s home at /web/admin/.
It has the sidebar’s sections, with a card for each item the user may open. /web/dashboard redirects
there. Without the admin module, the dashboard is /web/profile. This is configurable under :wagoe/user:
:wagoe/user
{:login-redirect {:user "/web/overzicht" :default "/web/overzicht"} ; by role
:dashboard false ; or {:handler my.app.web/dashboard}
:home "/web/overzicht" ; where the layouts link home
:landing "/web/welkom" ; where / and /web send everyone
:registration false} ; no self-registration
:login-redirect is a map of role to path (:default covers roles it does not name,
and without it they land on the dashboard), or a symbol naming a function of the user
and the login context (:ip-address, :user-agent, :request) that returns a path.
An application module can contribute it as :auth {:login-redirect (ig/ref …)}, a
function or a wagoe.user.ports/ILoginRedirect, the same way as :mfa-required?. A
function that returns nil or a path off this site sends the user to the default. It applies to password login, the MFA step, OpenID Connect sign-in and
/web for someone already signed in, unless :landing is set. A return-to on this site still wins.
A top-level :dashboard-extra-cards, a vector of Hiccup cards, still shows after the
dashboard’s sections, deprecated until 2.0. Give a module’s nav item a :description
instead.
:dashboard false leaves /web/dashboard to the application; {:handler sym} serves
that Ring handler there instead, behind the same sign-in check. Either way, users then
land on /web/dashboard.
:home is where the logo, "back" and dashboard links in the built-in layouts point. It
defaults to /web/dashboard, or to /web with :dashboard false, which sends a
signed-in user on to their landing page.
:landing gives the application its own front page. / and /web then redirect there
for everyone, signed in or not, instead of to the login page and the built-in welcome
page. Mark the route that serves it :public true, or a visitor who is not signed in
is sent to log in. :login-redirect still decides where someone goes after signing in.
Without the user module, a module that contributes a :static route at / replaces
the platform’s redirect.
:registration false turns off self-registration: /web/register is not mounted and
the login page drops its "Create Account" link. Accounts then come from an
administrator, an import, or an OIDC provider with :auto-create? true. It is on by default.
All five keys are checked at boot: a path off this site, an unknown role or a symbol that does not resolve stops it.
The login, MFA and registration pages use Wagoe’s layout and header. An application
with its own look sets :login-layout, a function of title, body and opts that
returns the whole page:
:wagoe/user {:login-layout my.app.web/login-layout}
(defn login-layout [title body {:keys [page flash]}]
(layout/page-layout title
[:div.login
[:img.hero {:src "/img/hero.jpg"}]
body
(when (= :login page)
[:a {:href "/web/wachtwoord-vergeten"} "Wachtwoord vergeten?"])]
{:css ["/css/app.css"] :skip-header true :flash flash}))
body is Wagoe’s form card: fields, CSRF token, error messages and the MFA step stay
Wagoe’s, so the layout cannot weaken them. Wagoe’s logo and subtitle are left out.
title is a [:t …] marker, translated with the rest of the page. opts has :page
(:login, :mfa, :oidc-mfa, :oidc-continue, :register or :mfa-enrolment), the Ring :request
(without the password and MFA code), :user, :flash, :return-to and :logo-url. An application
module can contribute it as :auth {:login-layout (ig/ref …)}, a function or a
wagoe.user.ports/ILoginLayout. Without it the pages are unchanged.
A permission is a keyword such as :invoice/approve; every module has <module>/read
and <module>/write, and declares the rest (see the platform library’s permission
registry). A role bundles permissions. Modules ship code roles, at least
<module>/viewer and <module>/editor; administrators compose managed roles from the
registry. A group holds roles, each with an optional scope, a map that narrows it. A
member holds the permissions of every role of every group they are in. How routes
and the admin enforce them, and how to switch an app on, is in
Authorization.
The :admin role makes a platform admin and needs no group; a user has it or no role
at all. Only a platform admin can give or take it. :role :user is still accepted and
means no role.
Everyone is a built-in group: every signed-in user is in it, including users who sign up later. Give it the roles any user should have.
Administrators manage groups at /web/admin/groups and roles at /web/admin/roles
(see Managing access),
where a code role can be duplicated into a managed one to adapt it.
/web/admin/permissions and each role page show who holds what, and a user’s Rights
tab lists their groups and every permission with the group, role and scope it comes
from. Joining or leaving a group, a
changed role assignment and a changed managed role are written to the audit trail of
each user whose rights change; a change to Everyone is written once, for the
administrator who made it.
(require '[wagoe.user.ports :as user-ports])
;; group-service is :wagoe/user-group-service
(user-ports/create-role group-service {:name :invoice/clerk
:description "Enters and approves invoices"
:permissions #{:invoice/read :invoice/write :invoice/approve}})
(user-ports/add-group-role group-service finance-id {:role :invoice/clerk :scope {:domain-id 4}})
(user-ports/add-group-role group-service wagoe.user.core.group/everyone-id {:role :invoice/viewer})
When users sign in through Entra ID or LDAP, map the provider’s groups to Wagoe group
names with a :group-membership hook. Those memberships count as if added by hand and
are not stored, so they follow the provider. A name with no Wagoe group gives nothing.
:wagoe/user {:group-membership my.app.auth/wagoe-groups}
(defn wagoe-groups [user]
#{"Newsroom"})
A module can contribute it instead, as :auth {:group-membership (ig/ref …)}, a
function or a wagoe.user.ports/IGroupMembership.
Declare what a scope may hold, so a bad one is refused. It is a Malli schema or a symbol naming one:
:wagoe/user
{:groups {:scope [:map {:closed true}
[:domain-id {:optional true :wagoe/entity :domains} :int]
[:director-general-id {:optional true} :uuid]]}}
Without it any map of EDN values is accepted. A scope is stored as EDN, so a uuid or
keyword in it reads back as one. A scope key with :wagoe/entity names the admin
entity it points at: the group page offers a lookup of its rows for that key, and the
rest of the scope stays EDN.
The :scope schema is being replaced by scope keys a module declares under
:authorization :scopes (see the platform library’s Scopes section). A :map schema
here is read in as those keys until 2.0; once any key is declared, a scope is checked
against the declared keys. An assignment taking a value from a :user/<name> source
that no module registers stops the application from starting.
Check rights on each request; nothing is cached, so a change applies to the next one:
(require '[wagoe.user.core.group :as group])
(let [grants (user-ports/user-grants group-service (:user request))]
(group/holds? grants :invoice/read) ; in any scope
(group/grants-for grants :invoice/approve) ; => [{:domain-id 4} …]
(group/authorized? grants :invoice/approve #(= domain-id (:domain-id %))))
A soft-deleted user holds nothing. Deleting a user or a group removes the memberships, and a group’s role assignments go with it.
To ask who holds a permission, say for a notification, use one query rather than walking the groups. Inactive users, and memberships from an identity provider, are left out:
(-> (user-ports/permission-holders group-service :invoice/approve) ; [{:user-id :permission :scope} …]
(group/users-with-permission #(= domain-id (:domain-id %)))) ; => [user-id …]
Each group is matched by name and created when missing; every group, member and role
is checked before anything is written, and the import runs in one transaction, audited
as the single changes are. It adds what is missing and removes nothing unless you pass
{:replace? true}, so running it twice is harmless. It creates the group tables
itself, so it can run from your own migration:
(user-ports/import-groups! group-service
[{:name "Finance"
:members [ada-id bob-id]
:roles [{:role :invoice/clerk :scope {:domain-id 4}}]}]
{})
Groups used to hold grants, a permission and a scope, and users had the role :user or
:viewer. The first start after upgrading moves them over, so nobody can do less and
nobody becomes an admin:
Each grant becomes an assignment of a managed role named after its permission.
The old rows stay until 2.0, so servers still on the previous version keep working
during a rolling upgrade; a grant one of them adds moves over at the next start.
Rights are read from the assignments only. add-group-grant, remove-group-grant
and an import’s :grants still work this way, and are deprecated. They take only a
permission a module declares, or one in :groups :permissions when that is set, and
never the name of a code role. A stored grant named after a code role is not moved;
the start logs it.
When there are users other than admins, Everyone gets every module’s editor role. That is what every
signed-in user could do; narrow it when you are ready. A role holding a permission
something already checks as a grant, such as :workflow/read, is left out, so the
upgrade gives nobody a right they did not have.
Users with :viewer join a group called Readers, which gets every module’s viewer
role, and keep no role. OIDC’s :default-role no longer gives a role.
An API-only deployment can drop the user module’s HTML pages:
:wagoe/settings {:features {:user-web-ui {:enabled? false}}}
The shipped configs read it from WAG_WEB_UI_ENABLED, so WAG_WEB_UI_ENABLED=false
does the same. It defaults to on.
| Removed | Kept |
|---|---|
|
|
|
|
|
|
| OpenID Connect sign-in ( |
Other modules are not affected: / still redirects to /web/login, and the admin UI
stays mounted while :wagoe/admin is active. Groups have no API; manage them through
:wagoe/user-group-service.
OpenID Connect sign-in lands on the admin’s dashboard by default; point :login-redirect
at a page that exists.
# Validate a session token
curl http://localhost:3000/api/v1/sessions/{session-token}
# End it (logout)
curl -X DELETE http://localhost:3000/api/v1/sessions/{session-token}
The token in the path is the credential. A user’s other sessions are listed and revoked on their Sessions tab in the web UI.
State-changing web requests are protected against Cross-Site Request Forgery by the
http-csrf-protection interceptor (in the default stack). A POST/PUT/DELETE/PATCH is
validated — and rejected with 403 on a missing or invalid token — when it is either
session-authenticated or targets a /web route. This covers the web UI, /web/admin,
and any session-authenticated /api route. Token-auth API clients that send only a
bearer token (no session cookie) are not CSRF-vulnerable and are not checked.
How a token reaches the browser and back:
On a page load the interceptor issues a session-bound token and the shared layout
renders <meta name="csrf-token" content="…">.
For HTMX requests, a global htmx:configRequest listener copies that token into the
X-CSRF-Token header automatically — no per-form work.
Plain <form method=post> forms carry it in a hidden __anti-forgery-token field
(added via (wagoe.platform.core.csrf/hidden-field)).
Unauthenticated flows (login, register, MFA) have no session yet, so the token is bound
to a SameSite=Strict csrf-session cookie minted on the page GET and validated on the
subsequent POST.
Configure under :wagoe/http :security :csrf:
:security {:csrf {:enabled? true
:secret #or [#env CSRF_SECRET #env JWT_SECRET]
:exempt-paths ["/api/v1/payments/webhook"]}} ; webhooks/callbacks
:enabled? defaults to false, so an upgrade cannot refuse forms that emit no token;
the framework’s dev, acc and prod configs set it to true. The secret defaults to
CSRF_SECRET, then JWT_SECRET. List endpoints
that legitimately cannot send a token (PSP webhooks, OAuth callbacks) under
:exempt-paths; a trailing /* matches on a path-segment boundary.
See platform library → CSRF protection for the interceptor internals and token format.
JWT_SECRET must be set; missing it causes startup failures (it is also the default
CSRF signing secret)
CSRF protection, when enabled, is enforced for session-authenticated, state-changing requests — see CSRF protection
Internal entities use :password-hash (kebab-case) — never :password_hash
The user library enforces account lockout after repeated failed login attempts. A
wrong current password on the password-change form counts too, and a locked account
cannot change its password
See user library for the full reference.
Can you improve this documentation? These fine people already did:
thijscreemers & Thijs CreemersEdit 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 |