Kanzo UI
Auth

Session

One session shape in the browser, bffAuth behind it, how a session is kept alive and how it ends, and why the current organization is resolved per request rather than stored.

import { AuthProvider, useSession, type Session } from "@kanzo-tech/auth";

A Session is the whole of what the client knows about who is signed in.

interface Session {
  user: AuthUser;
  roles: readonly string[];
  organizations: readonly Organization[];
  organization?: string;
  expiresAt: number;
}

interface AuthUser {
  id: string;
  email?: string;
  name?: string;
  username?: string;
}

interface Organization {
  alias: string;
  id?: string;
  roles: readonly string[];
}

user.id is the IdP's stable subject, never an email: an email can be reassigned to somebody else and a subject cannot. Every other field is optional because a realm decides which scopes it grants, and a token that merely omits one must not take the application down.

expiresAt is epoch milliseconds, and it is when the access token expires — the moment the next renewal is due, from the token response's expires_in. The client never uses it to decide access.

The current organization is resolved per request

The session says which organizations you are a member of. The request says which one you are looking at. Those are two different questions, and only the first is stored.

Membership comes from the token and is stable. The organization you are in is a property of the request — its host, its path, a cookie — so the server resolves it on every request with kanzoAuth's organization and hands it over as session.organization, on a server component's session() and on the /session route. It is never written into the session record, which is what lets two tabs sit in two organizations at once. Store it instead and those two tabs share one value: the last one to navigate wins in both, and the reader of the other is looking at a hall they did not open.

The switcher above sets the alias in component state because an example has no URL to read; a product's resolver reads it off the hostname or the path. organization is an address, not a proof of membership: can asks inside it by default, and answers false for an organization the person does not belong to.

The other half of the same separation is on the shape above: roles are the realm and client roles, global to the person, and the roles held inside an organization live on that Organization. They are never merged — being an owner of one says nothing about another.

The Backend For Frontend

RFC 10017 — the current best practice for OAuth in browser-based applications — names three architectures, and recommends one for business applications: the Backend For Frontend, where the tokens stay on a server of your own and the browser holds a cookie it cannot read. That is the one this package implements. Why only that one is on the auth layer's page.

The browser half satisfies one interface, which is the seam between the hooks and the session behind them:

interface Auth {
  getSession(): Promise<Session | null>;
  subscribe(onChange: () => void): () => void;
  signIn(options?: SignInOptions): Promise<void>;
  signOut(options?: { returnTo?: string }): Promise<void>;
  readonly fetch: typeof globalThis.fetch;
}

useSession and Gate are written against it, so a test double is five members and no network.

bffAuth

import { bffAuth } from "@kanzo-tech/auth";

export const auth = bffAuth({ basePath: "/api/auth" });

No engine, no protocol, no storage, and no token in the browser at all — which is why it sits on the root barrel beside the hooks. What it needs from the server is four routes under basePath: GET /session, /signin, /signout and POST /refresh. kanzoAuth serves them in Next; @kanzo-tech/auth/server is the relying party underneath, for a product that wires the routes into another router.

The answer from /session is checked rather than cast, by readSession. It is your own server on the other end and it is still data off the wire: a cast would let a deploy skew or a proxy's error page arrive as a Session whose user is undefined, and the failure would surface three components away as a property read on nothing. Anything unrecognisable reads as not signed in, which is the safe direction.

Keeping requests authenticated

The seam is the fetch, and a product already has exactly one. Handing your API client auth.fetch changes one line and no call site:

createClient<paths>({ baseUrl: "/", fetch: auth.fetch });

It attaches nothing — the cookie rides along on a same-origin request by itself — and acts on one answer only: a 401. A 401 is ambiguous here: the cookie was sent and was accepted, so what expired may be the access token behind it, which the browser half cannot see. So a 401 means "renew and try again" first: bffAuth POSTs /refresh and retries the request once if that worked. Only when the renewal is refused is the session over, and then it signs in, once, coming back to the page — one layer, so a product's data client needs no 401 → signIn() branch of its own, and six queries failing together navigate once.

How a session is kept alive, and how it ends

A cookie good for eight hours sits in front of an access token good for five minutes, and a realm that ends an SSO session after thirty minutes idle. What keeps those three clocks honest is the server, in three places, and the browser's 401 is the last of them:

  1. The proxy reads the session before every page and renews an access token with less than a minute left, in place under the same ticket. When the IdP refuses — the realm's session went idle, or was ended — the proxy drops the ticket, clears the cookie, and sends a navigation to sign in carrying its URL, so the person comes back to the deep link they asked for. A prefetch or an RSC fetch gets a bare 401 instead.
  2. The forwarder renews the same way before each request it forwards to a resource server, for a page that stays open longer than an access token lives.
  3. bffAuth answers the 401 that reaches the browser anyway, as above.

A session ended at Keycloak — an administrator's sign-out, another application's — reaches the server through back-channel logout, and the next navigation finds no session. There is no window in which the application draws for a session the IdP has closed: the record is gone, and every one of the three places reads the record.

The retry is once, and never on a 403: that is an answer, not a stale credential. A request whose body is a stream is not retried at all, because a body can be read once and a second send would deliver an empty one — silently, with a misleading error at the far end.

The renewal path is single-flight, and that is a rule rather than an optimisation. The specification requires a browser client's refresh token to rotate on every use, so a renewal spends the token it was called with. Ten requests noticing an expiring token in the same tick would fire ten renewals, nine of them replaying a token the first already spent — and an authorization server is entitled to read that as theft and revoke the whole chain. The session does not degrade under load; it dies under load, which is the worst way to find out.

singleFlight is on the barrel for a product's own call that must not run twice at once — the same primitive the renewal uses.

EventSource is fine here. It cannot carry an Authorization header, which is a problem for a browser holding a bearer token and none for one holding a cookie: a same-origin event stream through the forwarder carries the session like any other request.

When a credential is refused, or nobody answers

A boolean cannot be acted on. Not signed in sends the person to the IdP; signed in but not a member sends them to a page that says so; the IdP did not answer sends them nowhere, because it is an outage to name. Telling those apart is the difference between an explanation and a redirect loop. So a failure carries a code, in the area/kind grammar one host registry keys, and data with what was measured:

import { AuthError, type AuthErrorCode } from "@kanzo-tech/auth";
CodeWhat happened
claims/no-subjectThe claim set carries no sub. Not a session at all — a realm fault, never a person's
session/absentThere is no session: not signed in, or it expired
session/irrevocableThe store cannot end a session from the server — a stateless store, or a ticket adapter without keys. Back-channel logout answers 501
organization/invalidThe organization asked for is not an alias — a space in it would inject a second scope
callback/state-mismatchThe callback's state is absent, different, or matches no transaction
callback/nonce-mismatchThe ID token's nonce is not the one that was sent — a replay
token/exchange-failedThe token endpoint refused the code, or answered something unusable — no ID token, a client it does not know
token/refusedA token was refused and the session is over: the IdP answered invalid_grant to the refresh token, or a back-channel logout token did not verify
session/unavailableThe session could not be read: the store failed, or /session answered neither a session nor a 401. data.status is that answer
session/silent/session or /refresh did not answer within 30 s — or, on the server, a ticketStore adapter did not, reported as the cause of a session/unavailable. data.after is 30000
idp/unreachableThe IdP could not be reached, or answered with something that is not OAuth — a 5xx, a proxy's page
idp/silentThe IdP did not answer within 30 s. data.after is 30000

Every wait this package makes on another party ends in 30 s: bffAuth's requests to /session and /refresh, every call ticketStore makes on its adapter, and every request openid-client and jose make behind ./server, through each library's own timeout. What it threw is kept as the cause.

API Reference

useSession

Returns the context — AuthContextValue — plus the two actions and the role question, so a component that draws a sign-out button or an editor's control needs one hook rather than two.

FieldTypeDescription
sessionSession | nullWho is signed in
statusAuthStatus"loading" | "authenticated" | "anonymous" | "failed" — branch on this
errorunknownWhat getSession rejected with, exactly as thrown, while status is "failed"
authAuthThe instance the provider was given
signIn(options?: SignInOptions) => Promise<void>Send the person to the IdP
signOut(options?: { returnTo?: string }) => Promise<void>End the session
can(role: string, organization?: string) => booleancan bound to this session: inside session.organization unless told which

It throws when called outside AuthProvider, naming bffAuth in the message.

AuthProvider

PropTypeDefault
authAuth—
childrenReact.ReactNode—

One per application, at the root. A session that cannot be read settles on "failed", with what was thrown on error — neither "anonymous", which sends the person to sign in over an outage, nor loading, which is a spinner nobody can escape. Gate draws neither side while it is "failed".

bffAuth

BffAuthConfig:

PropTypeDefault
basePathstring/api/auth
fetchtypeof fetchthe global
navigate(url: string) => voidassigns window.location

SignInOptions

PropTypeDescription
returnTostringWhere to come back to. Defaults to the current URL
organizationstringAsk for one organization's scope instead of every one — see Keycloak

On this page