Require sign-in on a route¶
Keep signed-out readers off a route, send them to sign in, and bring them back to the exact page they asked for once they have.
(ns app.core
(:require [re-frame.core :as rf]
[re-frame.routing]))
(rf/reg-route :app/login {} "/login")
(rf/reg-route :app/settings
{:can-enter [:auth/signed-in?]}
"/settings")
(rf/reg-sub :auth/user (fn [db _] (:auth/user db)))
(rf/reg-sub :auth/signed-in? {:inputs [[:auth/user]]}
(fn [[user] _] (some? user)))
;; A refused entry: remember where the reader was going, then go to login.
(rf/reg-event :rf.route/entry-denied
(fn [{:keys [db]} [_ {:keys [destination]}]]
{:db (assoc db :auth/return-to destination)
:fx [[:dispatch [:rf.route/navigate {:to :app/login :replace? true}]]]}))
;; A successful sign-in: go back there, or to the articles list.
(rf/reg-event :auth/sign-in
(fn [{:keys [db]} [_ user]]
(let [return-to (:auth/return-to db)]
{:db (-> db (assoc :auth/user user) (dissoc :auth/return-to))
:fx [[:dispatch [:rf.route/navigate
(assoc (or return-to {:to :app/articles}) :replace? true)]]]})))
These registrations extend the articles reader.
Keep them in its app.core namespace, before the frame is created. The sample
sign-in event accepts a user directly; a real app dispatches it after the server
authenticates the user.
Render the sign-in and settings pages¶
(rf/reg-view login-page []
[:div
[:h1 "Sign in"]
[:button {:on-click #(dispatch [:auth/sign-in {:name "Ada"}])}
"Sign in as Ada"]])
(rf/reg-view settings-page []
[:h1 (str "Settings for " (:name @(subscribe [:auth/user])))])
Add :app/login [login-page] and :app/settings [settings-page] to page-for.
Render [rf/route-link {:to :app/settings} "Settings"] in the root header.
Signed out, click Settings: login opens. Click Sign in as Ada: settings
opens for Ada. Repeat with a pasted /settings#privacy URL; the return trip
keeps the fragment too.
The cell below runs the whole flow on an in-memory frame. Privacy settings
links to /settings#privacy, and the line under the page shows the route and
fragment after the return trip. Sign out clears the user and navigates to a
public page, so you can start again:
(require '[re-frame.core :as rf]
'[re-frame.routing])
(rf/reg-route :app/home {} "/")
(rf/reg-route :app/articles {} "/articles")
(rf/reg-route :app/login {} "/login")
(rf/reg-route :app/settings
{:can-enter [:auth/signed-in?]}
"/settings")
(rf/reg-sub :auth/user (fn [db _] (:auth/user db)))
(rf/reg-sub :auth/signed-in? {:inputs [[:auth/user]]}
(fn [[user] _] (some? user)))
(rf/reg-event :rf.route/entry-denied
(fn [{:keys [db]} [_ {:keys [destination]}]]
{:db (assoc db :auth/return-to destination)
:fx [[:dispatch [:rf.route/navigate {:to :app/login :replace? true}]]]}))
(rf/reg-event :auth/sign-in
(fn [{:keys [db]} [_ user]]
(let [return-to (:auth/return-to db)]
{:db (-> db (assoc :auth/user user) (dissoc :auth/return-to))
:fx [[:dispatch [:rf.route/navigate
(assoc (or return-to {:to :app/articles}) :replace? true)]]]})))
(rf/reg-event :auth/sign-out
(fn [{:keys [db]} _]
{:db (dissoc db :auth/user)
:fx [[:dispatch [:rf.route/navigate {:to :app/home}]]]}))
(rf/reg-view login-page []
[:div
[:h1 "Sign in"]
[:button {:on-click #(dispatch [:auth/sign-in {:name "Ada"}])}
"Sign in as Ada"]])
(rf/reg-view settings-page []
[:div
[:h1 (str "Settings for " (:name @(subscribe [:auth/user])))]
[:button {:on-click #(dispatch [:auth/sign-out])} "Sign out"]])
(rf/reg-view signed-in-app []
(let [{:keys [route-id fragment]} @(subscribe [:rf/route])]
[:div
[:nav [rf/route-link {:to :app/home} "Home"] " · "
[rf/route-link {:to :app/settings} "Settings"] " · "
[rf/route-link {:to :app/settings :fragment "privacy"} "Privacy settings"]]
(case route-id
:app/home [:h1 "Home"]
:app/articles [:h1 "Articles"]
:app/login [login-page]
:app/settings [settings-page]
nil)
[:p [:code (pr-str {:route-id route-id :fragment fragment})]]]))
[rf/frame-root {:id :app
:initial-events [[:rf.route/navigate {:to :app/home}]]}
[signed-in-app]]
How the guard works¶
:can-enter names a subscription. The runtime checks it on every way into the route —
a link, :rf.route/navigate, a typed URL, a refresh, Back/Forward, the first load,
and server rendering — so there is nothing to wire per entry point. An identical
navigation is a no-op and does not check guards again. Changing the session alone
also does not navigate: dispatch a navigation to a public page when signing out.
The sub must return true (enter) or false (refuse). Any other value refuses and
raises :rf.error/can-enter-non-boolean, which is why :auth/signed-in? wraps the
user in some?. The sub also receives the resolved target as the last element of its
query vector, (fn [inputs [_ target]] …), so one sub can answer differently by
(:route-id target) or (:params target).
A refusal commits nothing: no route change, no URL change, no scroll, no :on-match,
no resource load. Unlike a :can-leave block, nothing is parked to resume later. The
runtime dispatches :rf.route/entry-denied once, with:
{:destination {:to :app/settings}
:target {:route-id :app/settings :params {} :query {} :fragment nil :url "/settings"}
:cause :link ;; how the reader tried to get in
:requested-url "/settings"
:guard :auth/signed-in?}
The built-in handler does nothing, so without your own a refused click is ignored. Under server rendering the same refusal answers 403, and navigating to
login from your handler doesn't change that. To answer with a redirect instead, add
[:rf.server/redirect {:location "/login"}] to the handler's :fx; the client skips
that effect (the entry-denial 403).
Why the return trip works¶
:destinationis a complete navigate request. It is the destination the URL resolved to, including path params, query and fragment, so a refused/settings#privacyreturns to exactly that. Use it rather than rebuilding an address from:requested-url.:replace? trueon the way to login keeps the refused URL out of the history, so Back from/logindoes not hit the guard again.- The return is a new navigation. The guard runs again and, with a user present, allows it. If sign-in did not actually store a user, the guard refuses again and the denial handler returns to login. Keep login itself unguarded.
- Read and clear
:auth/return-toin the same event, so a later sign-in cannot send the reader somewhere stale. - The payload is already redacted in traces. The framework marks
:requested-url,:destinationand:targetas sensitive, and that still applies when your handler replaces the built-in one. Your handler receives the real values.
:auth/return-to is plain data, so it can equally live in localStorage to survive a
page reload.
Protecting several routes¶
Put the same guard on each route. A shared map is enough:
(def signed-in-only {:can-enter [:auth/signed-in?]})
(rf/reg-route :app/settings signed-in-only "/settings")
(rf/reg-route :app/article-editor
(merge signed-in-only
{:params [:map [:slug :string]]
:on-match [[:editor/open]]
:can-leave [:editor/can-leave?]})
"/articles/:slug/edit")
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
| A signed-out click on a protected link does nothing | No :rf.route/entry-denied handler, so the built-in one ignores it |
Register the handler above |
Entry is refused for a signed-in reader; :rf.error/can-enter-non-boolean is raised |
The sub returned the user map, nil, or another non-boolean |
Return (some? user) or (boolean …) |
Back from /login returns to the refused page and bounces to login again |
The redirect to login pushed a history entry | Navigate to login with :replace? true |
| After signing in, the reader lands on the articles list instead of the page they asked for | :auth/return-to was not stored, or was cleared before sign-in read it |
Store :destination in the denial handler; read and clear it in the sign-in event |
Navigation loops between the denial handler and /login |
The login route has the :can-enter guard too |
Leave the login route unguarded |
Reloading a protected page while signed in lands on /login |
The session is restored asynchronously and was not back when the URL was checked | Defer the redirect, as below |
Advanced¶
Deep links while a saved session is loading¶
If the app restores a session at boot by fetching the user with a saved token, the
first URL is checked before that reply arrives. A signed-in reader who reloads
/settings is judged with no user, and the handler above sends them to login.
The guard should still return false, because nothing protected may run while the
reader's identity is unknown. Make the denial handler tell "signed out" from "not
known yet": store the destination either way, and only redirect when no restore is in
progress. When the restore finishes, one event resolves the stored destination:
;; cf. examples/real-apps/realworld_http/routing.cljs and auth.cljs
(defn restoring-session?
"A saved token is present, but the user it belongs to has not arrived yet."
[db]
(and (nil? (:auth/user db)) (some? (:auth/token db))))
(rf/reg-event :rf.route/entry-denied
(fn [{:keys [db]} [_ {:keys [destination]}]]
(cond-> {:db (assoc db :auth/return-to destination)}
(not (restoring-session? db))
(assoc :fx [[:dispatch [:rf.route/navigate {:to :app/login :replace? true}]]]))))
;; Dispatch once the restore has finished, whether it succeeded or failed.
(rf/reg-event :auth/settle-deferred-entry
(fn [{:keys [db]} _]
(let [return-to (:auth/return-to db)]
(cond
(nil? return-to) {} ;; no protected deep link was waiting
(some? (:auth/user db)) {:db (dissoc db :auth/return-to)
:fx [[:dispatch [:rf.route/navigate
(assoc return-to :replace? true)]]]}
:else {:fx [[:dispatch [:rf.route/navigate
{:to :app/login :replace? true}]]]}))))
restoring-session? only sees the token if it is in app-db when the first URL is
checked, so put it there from the url-bound frame's :initial-events, which run
first.
On success the stored destination becomes an ordinary navigation, which the guard now
allows. On failure the reader lands on login with the destination still stored, so
:auth/sign-in returns them to it.
While the restore is in progress no route has been entered, so
@(subscribe [:rf.route/id]) is nil. Render a "restoring your session" state for
that case in the root view instead of a not-found page.
A policy that is not about routes¶
Use a frame interceptor only for a rule that spans many routes and does not belong in
route metadata: a maintenance-mode lockout, or a feature flag that closes a whole
section. The example below closes every route tagged :requires-auth
({:tags #{:requires-auth}} in its metadata) to signed-out readers.
An interceptor has to cover all three navigation events itself, or it lets navigations through:
| Event | Sent by | Payload |
|---|---|---|
:rf.route/navigate |
(dispatch [:rf.route/navigate …]) |
The request map: :to, :url, or an in-place edit |
:rf.route/url-requested |
A route-link click |
{:url …} |
:rf.route/handle-url-change |
The address bar, a reload, Back/Forward | The URL string |
Reduce each to one {:id <route-id> :params <map>} target, or nil, and decide once:
(ns app.auth-guard
(:require [re-frame.core :as rf]
[re-frame.routing :as rf.routing])) ;; match-url lives here
(defn- matched-id
"The route a URL matches as {:id :params}, or nil when nothing matches or the
params fail their schema (the runtime sends those to not-found)."
[url]
(when-let [{:keys [route-id params validation-failed?]} (rf.routing/match-url url)]
(when-not validation-failed?
{:id route-id :params (or params {})})))
(defn- nav-target
"Reduce a navigation event to {:id :params}, or nil. `current` is the route
slice, needed for an in-place navigate, whose target is the current route."
[[ev-id a] current]
(case ev-id
:rf.route/navigate
(let [{:keys [to url params]} a]
(cond
to {:id to :params (or params {})}
url (matched-id url)
;; in-place: a query or fragment edit on the current route
(and (nil? params)
(or (contains? a :query) (contains? a :query-merge) (contains? a :fragment)))
{:id (:route-id current) :params (or (:params current) {})}
:else nil)) ;; malformed: the runtime rejects it with :rf.error/navigate-bad-request
:rf.route/url-requested (matched-id (:url a))
:rf.route/handle-url-change (matched-id a)
nil))
(rf/reg-interceptor :app/auth-guard
{:doc "Redirect signed-out readers away from :requires-auth routes."}
{:before
(fn [ctx]
(if-let [{:keys [id]} (nav-target (get-in ctx [:coeffects :event])
(get-in ctx [:coeffects :rf.db/runtime
:rf.runtime/routing :current]))]
(let [route-meta (rf/handler-meta {:source :store :kind :route :id id})
needs-auth? (contains? (:tags route-meta) :requires-auth)
signed-in? (some? (get-in ctx [:coeffects :db :auth/user]))]
(if (and needs-auth? (not signed-in?))
(-> ctx
(assoc :rf/skip-handler? true)
(assoc-in [:effects :fx]
[[:dispatch [:rf.route/navigate {:to :app/login :replace? true}]]]))
ctx))
ctx))})
Each branch closes a way in:
{:url …}navigate goes throughmatch-url; otherwise[:rf.route/navigate {:url "/settings"}]gets past.- In-place navigate resolves to the current route; otherwise a reader whose session expired on a protected page could still change its query.
- A request with neither stays
nil, because the runtime rejects it anyway. - A URL whose params fail their schema stays
nil; it goes to not-found.
Setting :rf/skip-handler? in :before stops the original navigation, so the
protected route never commits and its :on-match never runs; the :fx then starts a
new navigation to login. Do not edit the event in :before instead: the runtime has
already chosen the handler from the original event id.
Attach the interceptor to the url-bound frame, and mount that frame with
frame-provider:
Events that are not navigations fall through the case at once. The rest of the
sign-in flow is in Add authentication.