Liking cljdoc? Tell your friends :D

Authentication & JWT

Wagoe’s user library handles the full authentication lifecycle: user registration, login, JWT tokens, sessions, and multi-factor authentication.

Prerequisites

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

Login

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"}}):

StatusWhen

200 with "requiresMfa": true

The password was right; send it again with mfaCode.

400 validation-error

The body is malformed or the email is not a valid address.

401 unauthorized

Wrong email or password. The body is the same whether or not the account exists.

401 mfa-verification-failed

Wrong mfaCode.

403 mfa-enrolment-required

MFA is required and the user has not enrolled.

429 account-locked

Too many failed attempts. Retry-After gives the seconds to wait. Unlike the 401, this tells the caller the account exists.

Protected routes

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}}]]

Multi-Factor Authentication (MFA)

MFA uses TOTP (Time-based One-Time Passwords, compatible with Google Authenticator / Authy).

Setup flow

# 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"}'

Login with MFA enabled

curl -X POST http://localhost:3000/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "password": "secret", "mfaCode": "123456"}'

Deciding per login

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.

Disable MFA

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"}'

Sign in with OpenID Connect

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:

  1. The account already linked to the token’s issuer and subject. That link outlives an email change at the provider.

  2. 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.

  3. 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.

  4. 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.

Landing page and dashboard

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.

Login page layout

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.

Groups, roles and permissions

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})

Memberships from an identity provider

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.

Scopes

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.

Checking rights

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 …]

Importing groups

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}}]}]
                           {})

Upgrading from grants and the user and viewer roles

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.

Turning the web UI off

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.

RemovedKept

/web landing page, /web/login, /web/register, /web/logout

/api/v1/auth/login, /api/v1/sessions, /api/v1/sessions/:token

/web/dashboard, /web/profile/** (incl. MFA setup and disable pages)

/api/v1/auth/mfa/**

/web/users/** (user management, sessions), /web/audit

/api/v1/users/**

/web/admin/groups/, /web/admin/roles/, /web/admin/permissions/**

OpenID Connect sign-in (/web/auth/oidc/**), when providers are configured

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.

Session management

# 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.

CSRF protection

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.

Key security notes

  • 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 Creemers
Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
←Move to previous article
→Move to next article
Ctrl+/Jump to the search field
× close