Guides
•4 min read
How to Build an Auth Flow with Token Refresh
Login, logout, session validation, and automatic token refresh – all declarative.
The Problem
Authentication touches everything: login forms, token storage, automatic refresh before expiry, protected route gating, and logout cleanup. Imperative approaches scatter auth logic across interceptors, timers, and route guards. When token refresh races with API calls, or logout doesn't clean up properly, users see flashes of protected content or silent failures.
The Solution
import { createModule, t } from '@directive-run/core';
const auth = createModule('auth', {
schema: {
token: t.string().optional(),
refreshToken: t.string().optional(),
expiresAt: t.number(),
user: t.object<{ id: string; role: string }>().optional(),
status: t.string<'idle' | 'authenticating' | 'authenticated' | 'expired'>(),
},
init: (facts) => {
facts.token = undefined;
facts.refreshToken = undefined;
facts.expiresAt = 0;
facts.user = undefined;
facts.status = 'idle';
},
derive: {
isAuthenticated: (facts) => facts.status === 'authenticated',
isExpiringSoon: (facts) => {
if (!facts.expiresAt) {
return false;
}
return Date.now() > facts.expiresAt - 60_000; // 1 min buffer
},
canRefresh: (facts) => !!facts.refreshToken,
},
constraints: {
// Auto-refresh when token is about to expire
refreshNeeded: {
when: (facts, derive) => derive.isExpiringSoon && derive.canRefresh,
require: (facts) => ({
type: 'REFRESH_TOKEN',
refreshToken: facts.refreshToken!,
}),
},
// Fetch user profile after authentication
needsUser: {
after: ['refreshNeeded'],
when: (facts) => !!facts.token && !facts.user,
require: (facts) => ({
type: 'FETCH_USER',
token: facts.token!,
}),
},
},
resolvers: {
login: {
requirement: 'LOGIN',
resolve: async (req, context) => {
context.facts.status = 'authenticating';
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: req.email,
password: req.password,
}),
});
if (!res.ok) {
throw new Error('Login failed');
}
const data = await res.json();
context.facts.token = data.token;
context.facts.refreshToken = data.refreshToken;
context.facts.expiresAt = Date.now() + data.expiresIn * 1000;
context.facts.status = 'authenticated';
},
},
refreshToken: {
requirement: 'REFRESH_TOKEN',
retry: { attempts: 2, backoff: 'exponential' },
resolve: async (req, context) => {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: req.refreshToken }),
});
if (!res.ok) {
// Refresh failed – force logout
context.facts.token = undefined;
context.facts.refreshToken = undefined;
context.facts.status = 'expired';
return;
}
const data = await res.json();
context.facts.token = data.token;
context.facts.refreshToken = data.refreshToken;
context.facts.expiresAt = Date.now() + data.expiresIn * 1000;
},
},
fetchUser: {
requirement: 'FETCH_USER',
resolve: async (req, context) => {
const res = await fetch('/api/auth/me', {
headers: { Authorization: `Bearer ${req.token}` },
});
if (!res.ok) {
throw new Error('Failed to fetch user');
}
context.facts.user = await res.json();
},
},
},
});
// Login form
function LoginForm({ system }) {
const { facts } = useDirective(system);
const loginStatus = useRequirementStatus(system, 'LOGIN');
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
system.dispatch({
type: 'LOGIN',
email: form.get('email'),
password: form.get('password'),
});
};
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" />
<input name="password" type="password" />
<button disabled={loginStatus.isPending}>
{loginStatus.isPending ? 'Signing in...' : 'Sign in'}
</button>
{loginStatus.isRejected && (
<p className="error">{loginStatus.error.message}</p>
)}
</form>
);
}
// Protected route
function ProtectedRoute({ system, children }) {
const { derived } = useDirective(system);
if (!derived.isAuthenticated) {
return <Navigate to="/login" />;
}
return children;
}
Step by Step
refreshNeededconstraint watchesisExpiringSoon– when the token is within 60 seconds of expiry and a refresh token exists, it emitsREFRESH_TOKEN. No timers needed.needsUserusesafter– it only evaluates afterrefreshNeededis settled, ensuring the user profile is fetched with a fresh token.Resolver handles failure gracefully – if refresh fails, the resolver clears tokens and sets status to
expiredrather than throwing, so the UI can redirect to login.system.dispatchtriggers login – the login form dispatches aLOGINrequirement directly, anduseRequirementStatustracks it through pending → fulfilled/rejected.
Common Variations
Logout with cleanup
// Add to the auth module's effects
effects: {
clearOnLogout: {
deps: ['status'],
run: (facts) => {
if (facts.status === 'idle') {
localStorage.removeItem('auth_token');
}
},
},
},
// Logout action
function logout(system) {
system.batch(() => {
system.facts.token = undefined;
system.facts.refreshToken = undefined;
system.facts.user = undefined;
system.facts.expiresAt = 0;
system.facts.status = 'idle';
});
}
Cross-module protected constraints
// In another module, gate on auth
const cart = createModule('cart', {
constraints: {
checkout: {
crossModuleDeps: ['auth.isAuthenticated'],
when: (facts, derive, cross) => cross.auth.isAuthenticated && facts.items.length > 0,
require: { type: 'CHECKOUT' },
},
},
});
Related
- Constraints –
after, priority, and cross-module deps - Resolvers – retry policies
- Multi-Module – cross-module composition
- Loading & Error States – status tracking patterns

