The auth layer
Why authentication is a sibling package rather than a component, why a browser draws instead of deciding, and why two deployment patterns share one session.
The auth pages are what @kanzo-tech/auth ships. This page is why it is shaped that
way — and more of it than usual is decided by somebody else, because the protocol, the claim
vocabulary and the architecture catalogue are all external and all citable.
Auth is a package, and the admission rule excludes it by name
@kanzo-tech/auth is a sibling of @kanzo-tech/ui, not a part of it. The first
admission rule is domain-free — nothing about RDF, SHACL, fossil,
graphs or auth, and auth is on that list by name rather than by inference.
The rule was written after the library shipped one. A sidebar composite carried a hard-coded log-out flow, its confirmation dialog and its untranslatable English copy included — an auth flow inside a library whose first rule excludes auth. The instructive part is the fix that did not hold: the flow became one entry in an array of menu items the caller passed in, which traded a domain leak for a layout tree written as an attribute, so the composite went too. A log-out item is markup a product writes out of the menu and sidebar parts, and the product owns its wording.
The package depends on nothing of @kanzo-tech/ui's, and that is not incidental. It lives in
this repository for the build, the release, the guards and the docs pipeline, not because it belongs
to the component library — which is what would make moving it later cost a directory rename and no
consumer's line. The budget on the root barrel is what notices the day that stops being true: a
recipe, a component or a second engine does not fit inside it.
What would reverse it: the first admission rule losing that word. There is no measurement here
and there should not be — this is a statement about what ui is for, and the evidence that it was
right is one composite, not a number. The cheap direction of reversal is the other one: because
nothing of ui's is imported, the day auth belongs somewhere else it can go there.
Held by packages/ui/src/index.test.ts, !SidebarUser and !InstanceSwitcher — the composite
that shipped the flow, asserted absent under both of the names it had;
packages/auth/package.json, size-limit.
The client draws; the resource server decides
The rule itself is on the package's front page, where a consumer meets it. What is here is why it is the first thing that page says, and what follows from it that would otherwise look like four unrelated decisions.
Two of them are realm configuration, and the Keycloak page is where they are
set. The role mappers are marked for both
tokens — the access token so an API can authorise,
the ID token so an interface can draw — and the access token carries an audience
mapper naming the API, because a resource server validates
aud before it trusts anything else. Neither is a Keycloak convention we happen to follow; each is
the rule applied once.
The third is a division of labour that is easy to lose: the package obtains and attaches the
credential and defines the claim model; validating is the API's, and ours are Rust. That
agreement is a contract in words — what is validated (iss, aud, exp, the signature via JWKS)
and what is authorised on (resource_access.<clientId>.roles,
organization.<alias>.resource_access.<clientId>.roles) — and the Keycloak page is its only copy, because a
second one in each language's source is a second thing to keep true.
The fourth is the rule one level down, and it is a subtraction rather than a warning: nothing in
this package decodes an access token, so there is no code to get wrong. Roles for drawing come from
the ID token's claims, read once on the server by the one claim reader and handed to the browser
through the session endpoint — so where did this role come from has one answer. Downward too: a
Session handed to a browser holds no token at all, because there is none in the browser to hold.
What would reverse it: nothing, twice over, and for two different reasons. The first is
physical: the decision has to be made where the attacker is not, and the attacker is on the other
side of the fetch; the only thing that moves is where the resource server is, which changes
nothing about who decides. The second is ownership: an access token's format belongs to the
authorization server and the resource that consumes it, and neither has promised the client sitting
between them anything. An authorization server publishing that format to clients as a contract
would reverse it — and publishing it is precisely what an access token is defined not to do.
Held by packages/auth/src/can.ts, can; packages/auth/src/can.test.ts, "is closed by default";
packages/auth/src/gate.tsx, Gate; packages/auth/src/server.test.ts, "exchanges the code and
reads the claims into a Session" and "holds no refresh token on the Session it hands out".
No guard can fail on a product that decides in the browser, and none here claims to — the code that
would be wrong is in the consumer. What the tests above hold is that the predicate answers false
for an absent session and never widens a role, so a product following the rule is not fighting the
library.
One pattern: the Backend For Frontend
RFC 10017 — a BCP, July 2026 — names three architectures for browser applications, and this package implements one of them.
| pattern | who here | why |
|---|---|---|
| Backend For Frontend | every host | "strongly recommended for business applications, sensitive applications, and applications that handle personal data" |
| browser-based OAuth 2.0 client | nobody, since 2026-10-05 | shipped as browserAuth on ./browser for a SPA with no server; removed with its oidc-client-ts peer when nothing consumed it |
| token-mediating backend | nobody | the norm itself says to evaluate a full BFF instead |
The second pattern shipped for agents/viewer, a Vite single-page application with no server; the
viewer runs from a vendored tarball that no npm release reaches, and no other consumer ever opened
the door. A door nobody opens is maintenance with no reader, so it went, and with it the second
implementation of Auth. The interface stays — useSession, Gate and a test double are written
against it — but there is one transport under it.
The obligation the BFF attaches — refresh tokens rotate on every use — is why the renewal path is single-flight: a rule rather than a performance argument.
What would reverse it: a consumer with no server and personal data to protect — the public client is then the only architecture available, and it comes back behind its own door with its own engine. Short of that, a revision of RFC 10017 that recommends the token-mediating backend.
Held by packages/auth/src/types.ts, Auth; packages/auth/src/bff-auth.test.ts, "attaches no
Authorization header, because the cookie rides along by itself"; packages/auth/src/server.test.ts,
"spends one refresh token for a burst of concurrent renewals"; packages/auth/src/single-flight.test.ts,
"runs the work once for every caller that arrives while it is running".
The protocol is somebody else's
Admission rule 3 is wraps, does not reinvent, so the first question was who had already done this. Four candidates, and two of them win half each.
| what it is | why it does not win the whole | |
|---|---|---|
| Auth.js | route handlers for Next, Qwik, SvelteKit, Express | owns the session model and drags a registry of providers nobody here uses |
| better-auth | an auth framework with SSO and organization plugins | "Better Auth requires a database to store user data" — it would own the user store Keycloak already owns, which is not a design but a synchronisation problem |
| oidc-client-ts | the browser protocol | won the browser half while there was one; left with ./browser |
| openid-client | the server protocol, and what Auth.js is built on | wins the server half |
So PKCE, the confidential client, discovery and RP-initiated logout are not written here, and
neither is JWT verification for a back-channel logout token, which is jose's. ./server is a
configuration and a set of verbs over openid-client, and
what is left after that subtraction is the whole of the package.
What would reverse the ./next choice specifically — and it is the one place Auth.js genuinely
competes, because it does that half entire. It loses on owning the session model and on dragging a
registry of providers nobody here uses, and what we ship instead is one object, kanzoAuth, whose
mechanisms are each borrowed from a named reference — the table on the Next.js
page. If our own cookie and session handling grows to rival Auth.js's in size, the answer is to
adopt it rather than to keep growing. That is a measurement anyone can take, and the size-limit
entry on dist/next.js is where it would first be visible.
Held by packages/auth/src/issuer.ts, discovery;
packages/auth/src/cookie-session.test.ts, "refuses a cookie sealed with another secret".
One door per engine, and the door everyone opens carries none
The placement rule is the library's own: a part belongs on a subpath only if it imports that
subpath's engine. Here that produces one door per engine — openid-client and jose behind
./server, next behind ./next — and, more importantly, a root barrel with no engine at all.
That is why the transport for the BFF pattern lives on the root rather than behind a door: with the token on the server there is no protocol left in the browser, only a fetch to a
session endpoint. Each of them is declared in the exports map on the day its file exists and never
before, so no commit ships a door pointing at a path Rollup did not write.
The precedent is not hypothetical. @kanzo-tech/graph/duckdb reached its coordinator through
@kanzo-tech/ui/analytics, whose barrel names @uwdata/vgplot on the way past — a static import, so
a host that installed the two peers the docs named still could not open the subpath, and graph had
ended up declaring vgplot an optional peer to hide a dependency it never mentions. The fix was a
package, @kanzo-tech/mosaic. The same shape in the other direction is what ./server must not do:
importing the root barrel to reuse the claim reader would drag a provider and three hooks into a
Node process, so modules inside this package import each other directly and never through the barrel.
None of this is visible to a door test, and that is the point of checking it the way it is checked.
A leaked engine does not make the barrel throw — it keeps working while pulling openid-client in
behind it, and the consumer finds out when a node: scheme breaks their web build. So the assertion
reads the bytes of the installed tarball, which is the only check in the repository that sees the
built artefact rather than the source.
What would reverse it: a measurement anyone can take — an engine that every consumer installs anyway is not paying for its door.
The client boundary in this package is never evaluated. Nothing checks that a module carrying
"use client" in src/ still carries it in dist/ — the install smoke test that compared those
bytes was deleted on 2026-09-29. The guard that asks whether the directive is in the right place,
packages/ui/src/client-boundary.test.ts, runs over the corpus in
packages/ui/src/guard-corpus.ts — every package declaring tailwind-variants — and auth
deliberately declares none, because nothing here draws. So graph and auth share one hole, and
auth is the worse half of it: its use-*.ts modules are hooks by name, where the likely error is
a missing directive rather than a surplus one. The fix is the corpus rule applied a second time —
widen it, never copy the guard — with that guard given its own derivation: every package
declaring react as a peer. It is named here rather than fixed because it is one edit to a shared
file that two packages' guards read.
Held by packages/auth/src/server.test.ts, "imports no
React, no barrel and no component from the server door".
Membership is stored; the current organization is resolved per request
The rule itself is on the Session page, with the two tabs that are the whole of the argument. What is here is what sits under it.
The product's resolver derives the current organization from each request — its host, its path, a
cookie — and the answer rides on Session.organization without ever being written to the record.
That makes the request the truth, and it has a second face: the address is a hint and never an
authority, so can resolves it against the signed
organization claim and answers false on a miss. Those two are one decision, and together they
are the line between a multi-tenant product and a leak between customers — the resolver says where
to look, the claim says whether to believe it.
Two claims about the realm sit under all of this, and the difference between them is worth writing
down, because it is the difference between a rule this repository can hold and one it cannot. That
the organization claim reaches the access token, carrying each organization's id and the
application's roles inside it, is held by a recorded token in the test corpus rather than a hand-written fixture — the
only thing here that would notice Keycloak changing its mind. That plain organization stops a
multi-organization sign-in on a chooser page instead of issuing a code is held by nothing, and
nothing here could hold it: it is a page Keycloak draws. It is why
the default scope is organization:*, where the
question never arises — and it is the shape of claim this section is most likely to be wrong about
later.
What would reverse it: a consumer whose organization is genuinely not addressable — no per-organization host, no per-organization route, nothing in the URL to derive from. Then the active organization has nowhere else to live and the two-tab cost is real rather than hypothetical. Every consumer so far addresses by hostname or by deployment.
Held by packages/auth/src/types.ts, Session; packages/auth/src/can.test.ts, "asks inside the
current tenant when none is named" and "is closed for a current tenant the person does not belong
to"; packages/auth/src/next-auth.test.ts, "reads the session the request carries, with the tenant
the resolver names"; packages/auth/src/claims.test.ts, "a real token, taken off a live Keycloak
26.8.0".
A role in one scope does not authorise in another
Roles held inside an organization live on that organization and are never merged into the global ones — not once, and not for convenience. Two separations are doing that work rather than one, at two levels, and only the second costs anything: it is why the claim reader takes a client id at all.
That second one is the application's key inside each organization's entry —
organization.<alias>.resource_access.<clientId>.roles
— and what earns it a place on this page rather than only on that one is the evidence behind it.
Keycloak itself keeps the two levels apart: the recorded token carries a person's roles in acme and
globex inside those entries and none of them in the top-level resource_access, so the session
copying them up would be the library undoing a separation the identity provider makes.
There is no hierarchy in the predicate either, and that is the same separation refusing a third time.
What would reverse it: nothing, and the reason is that the alternative is already expressible. A role meant to hold everywhere is a realm role or a client role, and a role meant to hold across a whole organization is the application's role mapped onto a group everyone there is in — both exist, both are read, and neither needs the sets merged. A deployment asking for the merge is asking for one of those two and has picked the wrong one.
Held by packages/auth/src/can.test.ts, "does not carry a role from one organization into
another" and "does not mix the global roles into an organization's, or the reverse";
packages/auth/src/claims.test.ts, "does not merge an organization's roles into the session's own"
and "keeps the organization's roles out of the session's own".
Roles are read; an organization's groups are not
An application's roles in an organization are the ones its administrator mapped onto the
organization's groups, and Keycloak writes them into the entry with composites expanded. The group
names ride along on the same entry and the reader never looks at them. This replaced a convention
that read them — a group path whose first segment named the application, /board/editor — and the
convention was wrong in the direction that matters: it made an organization's own arrangement of
its people into the application's authorization model, so a customer renaming a group revoked a
role, and every customer had to arrange its groups the way every application wanted. A group is
now whatever the customer calls it, and the only thing the application contributes is its roles.
A hierarchy sits on the same line. A product declares admin ⊃ editor ⊃ reader once, as composite
roles in its client registration, and the token carries the expanded set — so the browser and the
resource server read one order from the realm, rather than an owner || member spelled out at every
call site on both sides and kept in agreement by discipline. can never ranks, and the order is
still the product's; the realm is only where the product writes it.
What would reverse it: Keycloak withdrawing addGroupRoleMappings, or ceasing to expand
composites inside the entry — the recorded token is what would notice, since it holds a composite
and the role it contains arriving together. Or a product whose roles depend on which group
granted them rather than on whether they are held, which no mapping of roles can express and a group
name could; none has asked.
Held by packages/auth/src/claims.test.ts, "never reads a role from a group's name" and "reads
a composite with the role it contains, as Keycloak expanded it".
The signature check follows the channel, not a default
openid-client does not verify the ID token's signature in an authorization code grant, and it is
right not to: an ID token arriving over a TLS connection to the token endpoint, in the answer to a
request authenticated as this client, is vouched for by the channel — OpenID Connect Core
§3.1.3.7 step 6 says so, and that is why a sign-in normally fetches no JWKS at all.
This package keeps the exemption and derives it instead of inheriting it. The exemption is a claim
about the channel, and an internal origin of http://keycloak:8080 — which is what reaching
Keycloak inside a cluster looks like — withdraws the channel: the hop TLS was supposed to protect is
plaintext, so nothing is vouching for anything. Taking the reference's default there would be
inheriting its conclusion without its premise. So the default is read off the protocol of the origin
this process actually reaches, and it can still be set either way explicitly.
What makes the two origins differ at all is the origin rewrite, which is on the Server page because a consumer configures it. The part that matters here is only that it rewrites the transport and not the issuer, which is what leaves a plaintext hop to notice.
What would reverse it: a plaintext hop that is authenticated some other way — mutual TLS
terminated by a sidecar, a service mesh that proves both ends — which restores the premise without
restoring https:, and would make the derivation wrong in the safe direction rather than the
dangerous one. Upstream reverses it too: openid-client changing its default, or the specification
withdrawing the step-6 exemption, and then the derivation is doing work nobody asked for.
Held by packages/auth/src/issuer.ts, verifySignatures; packages/auth/src/server.test.ts,
"fetches no keys when the token endpoint is reached over TLS, because the channel vouches" and
"fetches the keys when the hop is plaintext, because then nothing vouches".
No sign-in screen ships
No sign-in screen, no user menu and no organization switcher — stated
on the front page rather than discovered, the same way @kanzo-tech/graph's opens with its own
absence, because a shape stated late reads as a gap.
The part that is a design decision and not a description: nothing in this package draws at all,
which is why it declares no tailwind-variants. That absence is what keeps it out of the six
appearance guards, correctly — they would be scanning for a shape it never has — and it is the
tripwire below, rather than a rule anybody has to remember.
What is genuinely shared sits underneath the screen, and it is what is here: the claims, the
predicate, the derivation, the fetch. The three screens it does not ship are ordinary
arrangements of @kanzo-tech/ui's parts, which is where an arrangement belongs — the sidebar page's
callout has said so since it said there is no logout affordance in the library.
What the documentation demonstrates instead is the model, because that is the half a reader
cannot infer from a picture of a screen: a claim set and the session it
becomes, and the decision made from
it. An arrangement drawn over a fake Auth can only show a button being
pressed; the protocol needs a realm, and that verification lives beside the realm.
What would reverse it: two products whose sign-in screens differ only in the logo. That is a comparison anyone can make by putting them side by side, and it is the same condition that would admit any other composite: a second call site that wants the same arrangement, not a first one that wants it once.
Nothing fails when a screen is added, and no guard could — a component that appears is not an
invariant that breaks. The nearest thing to a tripwire is the budget on the root barrel, which a
drawn component would blow, and the fact that a screen would have to bring tailwind-variants with
it, pulling this package into the appearance corpus in a commit somebody has to write. Both are
tells rather than gates.
Held by packages/auth/package.json, //peers and size-limit;
packages/ui/src/guard-corpus.ts, IDIOM.
A failure is not a signed-out person
The provider used to settle on anonymous when getSession rejected, on the argument that signing
in is recoverable and a spinner is not. Both halves were true and the conclusion was not: an IdP or a
session store that is down sends the person to sign in, the sign-in fails the same way, and nobody
on either side learns what broke. fossil's /docs/design/failure — every wait ends, every failure
is seen — puts it as one rule across fossil, kanzo-ui and its hosts: a failure reaches the host whole,
by its code, and within a bound.
So a session that cannot be read is a fourth status, "failed", carrying the thrown value
untouched; no session is said only by whoever can know it — a 401 from /session, or the IdP's
own invalid_grant to a refresh token. Every wait on
another party ends in 30 s and is coded by who did not answer, idp/silent or session/silent;
where openid-client or jose makes the wait, the figure is handed to its own timeout
rather than wrapped around it. A failed sign-in, callback or sign-out is a redirect to the product's
problemPage with the code in the URL, because those routes are reached through the address bar and
a JSON body there is what a person reads.
What would reverse it: a product that wants an outage to look like a sign-out — a public site
where an unreadable session should degrade to the anonymous view, and nobody is ever sent to sign
in. That product branches "failed" into its anonymous view itself; the library still does not
make the choice for it. The 30 s figure moves with the table on the failure page, not here.
Held by packages/auth/src/auth-provider.test.tsx, "settles on failed when the read fails,
holding exactly what was thrown"; packages/auth/src/bff-auth.test.ts, "reads a 5xx from the
session endpoint as a failure, not as nobody being signed in" and "gives up on a session endpoint
that never answers, after 30 s and not before"; packages/auth/src/next-routes.test.ts, "sends a
callback with no transaction to the problem page, with its code"; packages/auth/src/next-auth.test.ts,
"sends a navigation to the problem page when the store does not answer"; packages/ui/src/failures-reach-the-host.test.ts.
The services
Why a capability's server half ships beside its client half, in one repository under one tag — and why the service names no application and carries no production configuration.
The navigation layer
Why the unsaved-changes guard is a sibling package, why it copies TanStack Router's shape, why Next needs a door of its own, why nothing in it patches history, and why a route's sidebar intent is not the person's preference.