SessionManager
The token lifecycle the browser client runs for you, and how to drive it directly.
The browser client keeps a session alive without you doing anything: it stores the tokens, notices when the access token is about to expire, exchanges the refresh token for a new pair, and raises an event when it cannot.
You never call it directly — the browser client owns it.
auth.getAccessToken() and
auth.refreshSession() are the public doors and both go
through here. This page is for when you need to know what is happening
underneath.
Browser only. createServerClient has no session manager, because a server holds
no single user's session to keep alive.
What is stored
access_tokenstringRequiredWhat goes on the wire.
refresh_tokenstringOptionalWhat buys the next one. Without it a session cannot survive expiry.
expires_atnumberRequiredAn absolute epoch timestamp, computed on save as now + expires_in. Defaults
to one hour when the server does not say.
userUserOptionalCarried so the client can answer "who is signed in" without a request.
Note it stores an absolute expiry, not the relative expires_in the server
returns. A stored session is therefore still correct after the tab was asleep
for an hour, where a countdown would not be.
getValidToken
getValidToken() → string | null
Returns a usable token, refreshing first if it is close to expiring.
const token = await nulvless.auth.getAccessToken(); // goes through thisThe rule is a 90-second buffer: if the token expires in less than that, it is refreshed before being handed back. That margin exists so a token is never handed out and then rejected mid-flight — the request would have failed between your check and the server's.
Concurrent callers share one refresh. Ten requests that all find the token stale produce one exchange, and the other nine wait for its result — across tabs too, so two open tabs do not race each other for the same refresh token.
Returns null in three cases, and they are worth telling apart:
no sessionnullOptionalNothing stored. Nobody is signed in.
no refresh tokennullOptionalA session exists but cannot renew itself. Raises unauthorized.
refresh refusednullOptionalThe server refused the refresh token — a 401, or a token that is unknown,
already used, issued elsewhere, or belongs to a banned user. The stored session
is cleared and unauthorized is raised.
A refresh that merely could not complete — no network, a timeout, a 429,
a 5xx — is retried up to three times with backoff. If it still fails, null
comes back but the session is kept, so a flaky connection never signs anyone
out.
This is why holding on to a token string defeats the mechanism. Ask per request:
await fetch('/api/projects', {
headers: { Authorization: `Bearer ${await nulvless.auth.getAccessToken()}` },
});refreshSession
auth.refreshSession() → string | null
Renews the access token now, regardless of how much life it has left, and
shares the attempt with any renewal already in flight. Returns the new token, or
null when the session could not be recovered.
const token = await nulvless.auth.refreshSession();Reach for it after something changed server-side that the current token does not carry yet — new scopes after accepting an invitation, for example.
forceRefresh
forceRefresh({ deauthOnFailure }) → string | null
What refreshSession calls, and what the client calls itself after a 401 or a
403 forbidden_resource_access. Refreshes regardless of how much life the
current token has — unless another tab already replaced it, in which case that
token is returned without a second exchange.
deauthOnFailurebooleanOptionaldefault trueWhat happens when the refresh itself fails.
trueClear the stored session and raise unauthorized. The right behaviour
after a 401 — the session really is over.
falseLeave the session intact and just return null. For a soft refresh,
where the original failure was about permissions rather than identity.
The distinction matters. A 403 forbidden_resource_access means the user is
signed in and not allowed — deauthenticating them there would sign someone out
for clicking the wrong button. Pass deauthOnFailure: false so the caller can
surface the real error.
Scheduled renewal
With autoRefresh on (the default), the browser client does not wait for a
request to find the token stale. Every 30 seconds — and as soon as a hidden tab
becomes visible again — it checks the stored session and renews it once it is
inside the 90-second buffer.
- Hidden tabs skip the check. A background tab does not keep a session warm that nobody is looking at.
- One tab renews at a time. Tabs of the same project coordinate through the Web Locks API where it exists; a tab that finds the lock taken skips that tick.
- Every tab hears about it. The new token is broadcast over
BroadcastChannel, and each tab emitstoken-refreshed.
Turn it off with createClient(url, key, { autoRefresh: false }) when your host
controls renewal itself. Renewal on use still happens.
Call auth.dispose() when you throw a client away — tests, hot reload, a
micro-frontend unmounting. It stops the timer and closes the cross-tab channel.
Callbacks
Three hooks drive everything above. The browser client wires them to the events you subscribe to, so subscribing is normally enough.
setRefreshFn(refresh_token) => Promise<Result>OptionalHow to exchange a refresh token. Set by the client at construction — it is injected afterwards rather than passed in, to avoid a circular dependency between the manager and the HTTP client.
setOnTokenRefreshed(auth) => voidOptionalFires after every successful refresh. Becomes the token-refreshed event.
setOnUnauthorized(failed) => voidOptionalFires when the session cannot be renewed. Becomes the unauthorized event,
and carries the session that failed so a multi-account app can drop the right
entry.
save, get, clear
save(auth) · get() · clear()
The storage primitives. save computes expires_at and writes through the
configured storage adapter; clear removes the active
session and nothing else — the accounts store is untouched, which is what lets
deselect drop a session and still switch back
to it.
signOut calls clear for you, and only after the server confirms — so a
failed sign-out does not leave the client locally signed out and remotely alive.