Kanzo UI
The design of Kanzo UI

How the auth layer fails

Why a failure is not a signed-out person, why the session arrives with the page, why every refusal is a problem body, why a failure at a required session is the problem page, and why one party bounds each wait; and what would reverse each rule.

The auth layer is why the package is shaped as it is; hardening is what it does to resist attack. This page is the third half, split out of the first to keep both readable: what the layer does when something goes wrong, and says so in one shape.

A failure is not a signed-out person

The provider once settled on anonymous when the session read failed, on the argument that signing in is recoverable and a spinner is not. An IdP or a session store that is down sent the person to sign in, the sign-in failed the same way, and nobody learned what broke. fossil's /docs/design/failure — every wait ends, every failure is seen — and this library's page put it as one rule: a failure reaches the host whole, by its code, and within a bound.

Under the edge, three answers are kept apart on the server, where the person is read. No token forwarded is null: nobody is signed in, and only the edge can make that so. A token that does not verify is token/refused — a forged header, another client's token, an expired one. Keys that could not be fetched are idp/unreachable or idp/silent, an outage, never read as a refusal: the token may be perfectly good. jose makes the wait for the keys, so it is handed the figure rather than wrapped in one. A plain Error, not a Problem, is a programming or deployment fault, such as useSession outside its provider or a csp that allows 'unsafe-inline'; a code invites a catch that routes it to a sign-in page, the wrong answer to you wired this up wrong.

What would reverse it: a product that wants an outage to look like a sign-out, such as a public site that degrades to the anonymous view. It catches the Problem from session() and renders that view itself; the library does not make the choice for it.

Held by packages/auth/src/server.test.ts, "names the realm unreachable, not the token refused, when its keys cannot be fetched" and "has no session when the edge forwarded no token"; packages/auth/src/next-auth.test.ts, "throws the outage to a page that did not require a session, which reads it itself"; packages/ui/src/failures-reach-the-host.test.ts.

The session arrives with the page

The server reads the person once per request and hands the Session to AuthProvider as a prop; the browser reads nothing, caches nothing and renews nothing. It replaced a session read like any other datum: a query on the host's QueryClient through the read door, read again on focus, after a renewal and when another tab announced one on a BroadcastChannel, by a transport (bffAuth) that renewed a session read within a minute of its expiry. All of that kept a browser's copy of a session the browser held a cookie for. Under the edge the browser holds the edge's cookie, the edge renews, and the next page the server renders reads the renewed token, so the copy is as old as the page and that is enough for what it is for: drawing. @tanstack/react-query is no longer a peer of @kanzo-tech/auth.

What it costs: a tab that stays on one page does not hear a sign-out in another tab until it navigates or calls the API, and then the edge answers for the session. Nothing it draws decides anything.

What would reverse it: a page that must redraw on a sign-out elsewhere without navigating — a long-lived dashboard showing who is signed in. Then the provider reads again on focus, and the read is a query on the host's client as the read door says.

Held by packages/auth/src/auth-provider.tsx, AuthProvider; packages/auth/src/gate.test.tsx, "shows the fallback to an anonymous visitor, not the children".

Every refusal is a problem body

Every refusal with a code that the auth layer sends is an RFC 9457 problem: application/problem+json with status, title and detail, and the library's code and data as extension members; and it is the one Problem of the kernel, with its status read from the generated registry, not from a table kept here. Under the edge the forwarder is the one part that answers a browser's request itself, so it is the one that sends them. There were five shapes on the wire once: routes answering { error, message }; a forwarder computing a code and sending a bodiless status, so keasy coined bff/failed for an organization/denied the library knew; a problem page's redirect carrying ?code=; two status tables that disagreed; and keasy's Rust server answering application/json with no status.

The shape and why type is absent are the kernel's. One thing keeps its shape: a required session that fails is a redirect to the product's problemPage with the code in the URL, because a page is what a person reads. An answer about the request rather than a credential — a cross-site request's 403, a path under no mount — has no code to name and no body.

The server speaks it too, in the same words. keasy's Rust server answers application/problem+json, and its 401 is session/absent; the two vocabularies became one in the failure page.

A 401 without WWW-Authenticate is a stated deviation. RFC 9110 §15.5.2 says a server generating a 401 MUST send a WWW-Authenticate header with at least one challenge, and no 401 this layer sends carries one. The browser's credential is the edge's cookie, which has no HTTP authentication scheme to name, and a made-up scheme is a challenge no client can answer.

The forwarder names one party besides the realm: an API that does not answer is api/unreachable, 502, and one that does not answer its headers in time is api/silent, 504 (Hardening). Did not answer means fetch rejected with a network error, or the bound fired; an API that answers 500 has answered and its answer passes through as it came, and a client that went away is not the API's failure. The problem carries no data naming the upstream's URL, which is the deployment's.

The reference is RFC 9457, the body fossil's problem and Problem already share.

What would reverse it: a client of the forwarder that reads OAuth's error response — error, error_description — and cannot read a problem; none does. And a client that acts on a missing WWW-Authenticate, which would make the challenge worth inventing.

Held by packages/auth/src/next-proxy.test.ts, "answers 401, as a problem naming session/absent, without asking upstream when the edge forwarded no token", "answers 401, as a problem naming token/refused, and reaches nothing, for a token that does not verify", "answers 403, as a problem naming organization/denied, and reaches nothing, for an organization the person is not a member of", "answers 502, as a problem naming api/unreachable, when the API refuses the connection" and "passes an API's own 500 through, because an answer with an error status is still its answer". The server's problem answer is to be held by a test in the server's own suite and by the generated table's round trip.

A failure at a required session is the problem page

kanzoAuth().session({ required: true }) answers a refused token or an outage reading the realm's keys with a redirect to problemPage and the code, and a missing token with a redirect to the edge's sign-in, back to the page. Without it the outage would be thrown raw, and keasy wrapped every layout's read in a try that redirected to the same page with the same code: the library's own decision written again by the product. A refused token goes to the problem page and not to sign-in, because the edge holds a session and would forward the same token: that is a loop. Without required the failure is still thrown, for a page that renders through one. TenantRequest, the type KanzoAuthConfig.organization takes, is on ./next with it. The reference is AuthKit's withAuth({ ensureSignedIn: true }), which settles the outcome rather than handing the host an exception to settle.

What would reverse it: a layout that must render through an outage. Then it does not ask for required, and catches the Problem itself.

Held by packages/auth/src/next-auth.test.ts, "sends a required session's outage to the problem page, with its code", "sends a required session's refused token to the problem page, with its code" and "throws the outage to a page that did not require a session, which reads it itself".

One party bounds each wait

The package makes two waits and bounds both: the realm's keys, 10 s, handed to jose's own timeout as idp/silent; and the forwarder's hop to an API, 25 s until its headers, through the kernel's within as api/silent. A host bounds its own API calls in the requests it makes. The rule is the failure page's: the party that makes the request bounds it, and where a library makes it, the library is handed the figure. The browser half makes no request at all since the session became a prop, so the 30 s bound on its session read went with it.

What would reverse it: a host that cannot bound its own requests — one whose API client takes no signal. Then the package would need a transport of its own in the browser again, which the edge made unnecessary.

Held by packages/auth/src/forwarded.ts, KEYS_WITHIN; packages/auth/src/next-proxy.test.ts, "answers 504, as a problem naming api/silent, when the API sends no headers within the bound".

On this page