Kanzo UI
Auth

Server

The confidential client behind @kanzo-tech/auth/server — seven methods, strings in and strings out, a sealed cookie, a stable ticket renewed in place, and back-channel logout through the store.

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

This door is the server half of the Backend For Frontend: the tokens live on your server and the browser gets a cookie it cannot read. It needs openid-client and jose, and it reaches no React at all. kanzoAuth is this door already wired into Next; read on for what it is made of, or to put it under another framework.

What it answers is a RelyingParty and not an Auth: the seven methods below are begin, complete, read, token, refresh, end and logout, the protocol's verbs, so the name carries no Auth suffix — the suffix is a promise.

Nothing here implements OAuth. openid-client runs the flow, verifies the ID token and builds the end-session URL; jose seals the cookie. What is written here is the short sequence a route handler needs, the cookie discipline around it, and Keycloak's claims read into one Session.

Strings in, strings out

There is no Request in any signature, and that is deliberate: a Request parameter would make one framework's flavour of it the one that fits. A URL and a Cookie header go in; a URL and Set-Cookie values come out, so the same methods sit under an App Router handler, an Express route or anything else.

const party = relyingParty({
  clientId: "board",
  issuer: "https://id.kanzo.tech/realms/kanzo",
  secret: process.env.AUTH_SECRET!,
  store: ticketStore(adapter),
});
MethodAnswersFor
begin({ redirectUri, returnTo?, organization? })Redirect — the authorization URL, and the cookie that remembers this attemptGET /api/auth/signin
complete({ url, cookie, redirectUri })SignedIn — a session, its cookies, and where the person was goingthe callback
read(cookie)Session | null — one store read, nothing renewedGET /api/auth/session, a server component
token(cookie, options?)Token | Ended — the access token, renewed in place when near expirythe proxy, forwarding to a resource server
refresh(cookie)Renewed | Ended — renewed now, whatever the expiryPOST /api/auth/refresh
end(cookie, options?)Redirect — clears the cookie and ends it at the IdP tooGET /api/auth/signout
logout(logoutToken)nothing — drops the sessions a back-channel logout token namesPOST /api/auth/backchannel-logout

redirectUri is per call because it is derived from the request that asked, and one relying party serves every origin a deployment answers on; complete sends the token endpoint the same URI begin sent the authorization endpoint, as RFC 6749 §4.1.3 requires, whatever host the callback arrived under.

Redirect, SignedIn, Renewed, Token and Ended are the answer shapes, and every one of them carries its cookies as Set-Cookie values for the caller to attach. A handler that forgets to attach them is a handler whose sign-in appears to work and whose session is never there.

Ended is a result, not an exception. It says there is no live session — none was presented, the store no longer knows it, or the IdP refused to renew it — carries a code saying which (session/absent or token/refused), and carries the Set-Cookie that clears the browser's cookie. It used to be a thrown error that cleared nothing, which is how a refused renewal left a zombie: a cookie and a row that outlived the IdP's session by hours, and an application drawn for someone Keycloak had already signed out.

The token-mediating backend

token is the credential half of read. It answers the access token, which is what a resource server takes, and renews it when less than renewWithin seconds remain — sixty by default, which covers both the flight time of the request about to be sent and the clock skew between this server and the one that will validate the token.

const held = await party.token(request.headers.get("cookie"));
if (held.ended) return unauthorized(held.cookies); // 401, clearing the cookie


const upstream = await fetch(REPORTS, {
  headers: { authorization: `Bearer ${held.accessToken}` },
});
const answer = new Response(upstream.body, upstream);
for (const cookie of held.cookies) answer.headers.append("set-cookie", cookie);
return answer;

cookies is empty unless the ticket itself changed, which only the stateless store's re-seal does. kanzoAuth().api is that whole handler, already written.

The access token is the credential, and the ID token is not. A product without a way to ask for the first reaches for the second, which works on a realm that happens to put the same audience in both and stops working the day the resource server checks typ == "Bearer" — which is what it should be doing. An ID token says who signed in; it was never a key to an API, and it is not what /token/introspect or /revoke take either.

Renewal

The ticket is issued once, at sign-in, and never again. That is Duende BFF's server-side session, and it is also the session-fixation defence: whatever cookie a browser arrived at the callback with, it leaves with a ticket nobody has seen. After that a renewal spends the refresh token and writes what comes back in place, with store.update(ticket, record), so under a ticketStore the cookie is the same cookie for the whole session. Two tabs, or a proxy renewal and a request already in flight, never hold two different cookies for one session.

Renewal is single-flight per ticket, for the whole process: a page that fires eight requests at an expiring token spends it once, and the proxy, the forwarder and the refresh route all join the same slot.

Two Node processes behind a load balancer can still both spend one refresh token, and the IdP answers the second with invalid_grant. That is not the end of the session: the first process has already written the rotated token under the shared ticket, so on invalid_grant the record is read once more, and a refresh token that changed is taken and used. Only when it has not changed is the session over — the ticket is dropped and Ended clears the cookie. An IdP that did not answer, or a token endpoint that refused for any reason other than the grant, ends nothing: it is reported as the outage or the deployment fault it is.

Session.expiresAt is the access token's expiry, from the token response's expires_in, which is when the next renewal is due.

Sign-out goes through buildEndSessionUrl, not a URL assembled by hand: it discovers the end-session endpoint and sends the id_token_hint, and a hand-built URL gets both wrong with neither failure visible.

The cookies

One mechanism, used twice: for the session that outlives a request, and for the short-lived transaction that carries state, nonce and the PKCE verifier between the two legs of the code flow. Keeping the transaction in a cookie rather than in server memory is what makes this stateless by default.

The transaction cookie is one per attempt, named by its state — __Host-kanzo-auth.<state>, Auth0's __txn_{state} — so two tabs signing in at once both finish, and each callback clears the one it spent.

sealedCookie is that mechanism, SealedCookieConfig configures it, SealedCookie<T> is what it returns, and cookieValue is the one-line reader for pulling a named cookie out of a Cookie header. Four attributes, each for a reason:

__Host- prefixForces Secure and Path=/ and forbids Domain. Without it, anything that can serve a sibling subdomain can set a cookie that arrives looking exactly like ours — and the prefix is not declinable here
HttpOnlyIn a BFF the browser is not supposed to hold the credential at all; this is that sentence enforced
SameSite=LaxNot Strict. Strict withholds the cookie on the navigation back from the IdP, so the callback arrives without the transaction it needs and every sign-in fails
JWE, not a signatureThe payload is a refresh token. Signing would authenticate it and leave it readable to anyone who can see the cookie

A browser is only required to keep 4 KB of cookie, and an oversized one is dropped silently — no error, a sign-in that appears to work, and a session that never appears. Sealing throws with that byte count rather than letting it happen, and the way out is ticketStore.

statelessStore is the default and it holds nothing: the ticket is the record, so update re-seals it and the cookie is reissued. Two things it cannot do, both stated here rather than discovered later.

drop does nothing, and dropAll refuses with session/irrevocable. Signing out clears the cookie, which is enough for the person holding the browser and is not enough for anybody else: a copy taken beforehand keeps working until it expires. Under this store a session lifetime is a real security parameter, and neither sign out everywhere nor a back-channel logout is implementable — Auth0's SDK requires a session store for back-channel logout for the same reason.

It does not fit a record carrying an access token, and the numbers are the argument. Sealed with the three tokens a token-mediating backend holds, a realistic Keycloak record — two organizations and the roles that come with them — measures 6407 bytes against a limit of 4096. Without the access token it measures 4068, which is 28 bytes of margin and not a design: one more organization goes over it. store.test.ts holds both figures. So the stateless default is for a product that reads identity and calls no resource server; the moment there is an API to call, the cookie carries a ticket instead of the tokens.

SessionStore is five methods, and SessionRecord is what they carry — the session and the IdP's sid, plus the access, refresh and ID tokens the browser must never see. The tokens live on the record and not on Session for exactly that reason: a type carrying both would make the leak one typo away.

interface SessionStore {
  put(record: SessionRecord): Promise<string>;                  // at sign-in: a new ticket
  update(ticket: string, record: SessionRecord): Promise<string>; // on renewal: the ticket to carry
  get(ticket: string): Promise<SessionRecord | null>;
  drop(ticket: string): Promise<void>;
  dropAll(subject: SessionSubject): Promise<void>;              // back-channel logout
}

SessionSubject is { sub, sid? }, the two claims a logout token names: every session of a person, or the one Keycloak just ended.

A deployment that needs one live session per person enforces it in put, by dropping that person's previous ticket as it issues the new one.

ticketStore, and the database you already have

No backend ships here — a driver is a dependency and a deployment decision, and neither is this package's to make on its way past. What does ship is the shape, because without it every product that wants a real sign-out writes its own sealing, and a logout that invalidates nothing is the thing they all ship instead.

kanzoAuth(() => ({
  …,
  store: ticketStore({
    read: (key) => redis.get(key),
    write: (key, value, ttl) => redis.set(key, value, { EX: ttl }),
    replace: async (key, value, ttl) => (await redis.set(key, value, { XX: true, EX: ttl })) === "OK",
    delete: (key) => redis.del(key),
    // node-redis 5 yields a batch of keys per SCAN step
    async *keys(prefix) {
      for await (const batch of redis.scanIterator({ MATCH: `${prefix}*` })) yield* batch;
    },
  }),
}));

The cookie then carries an opaque ticket of 256 bits from the CSPRNG — under 400 bytes sealed, whatever the realm puts in a token — and the record lives in your own database. drop deletes, so every copy of that cookie ends at once, and update writes under the same key, so a renewal leaves the cookie alone. That write is the adapter's replace — Redis SET … XX, SQL UPDATE and its row count — which overwrites only a row that still exists. A back-channel logout that deletes the row while a renewal is in flight therefore stays a logout: the renewal finds nothing to replace and ends the session, instead of writing it back.

A ticket is <sub>:<sid>:<random>, each part percent-encoded. The random part is the whole of the security; the subject and the IdP session id are neither secret nor trusted on the way back in — the record is read from the row, never from the key. What the prefix buys is the query a flat random key makes impossible: every session belonging to this person, or the one Keycloak just ended. dropAll is that query, through the adapter's optional keys(prefix) — Redis SCAN MATCH, SQL LIKE. The prefix never contains a glob metacharacter (a subject spelled * is encoded), so the Redis pattern is literal as written; a SQL adapter still escapes % and _. Without keys, everything works except back-channel logout, which answers session/irrevocable.

ttl defaults to eight hours and should be set to the maxAge you gave relyingParty, which is the lifetime of the cookie carrying the ticket. Honouring it is the adapter's job, because every store that could hold this already has an expiry of its own — EX on Redis, a column and a sweep on SQL — and a timer here would be one that dies with the process.

Bounding the wait is not the adapter's job. ticketStore races every read, write and delete — and each dropAll — against the package's 30 s deadline, and one that has not answered rejects as session/silent with { after: 30000 } — which relyingParty reports as session/unavailable with that as its cause, like any other failure of the store. So an adapter is the three driver calls above and nothing else. What stays the deployment's is the driver's own configuration: a connect timeout and no offline queue are what make a store that is down refuse at once, rather than at the deadline.

Back-channel logout

logout(logoutToken) is the relying party's half of OpenID Connect Back-Channel Logout 1.0. It verifies the token as §2.6 lists: the signature against the realm's published keys, iss against the issuer, aud against the client id, iat present, the http://schemas.openid.net/event/backchannel-logout event present, and no nonce — which is what keeps an ID token from being replayed as a logout token. Then it calls store.dropAll with the token's sub and, when present, sid.

The keys are jose's remote key set over the discovered jwks_uri, fetched through the same transport as discovery — an internalOrigin applies to it too. A kid it has not seen re-fetches the set, at most once per cooldown, which is how a key rotation is survived without a timer.

A token that does not verify rejects with token/refused; a store that cannot end a session from here rejects with session/irrevocable. §2.4 allows a token naming only sid, and this one is refused too: tickets are found by subject first, and Keycloak always sends sub.

Discovery, and reaching Keycloak from inside a cluster

import { issuer, rewriteOrigin, type Issuer, type IssuerConfig } from "@kanzo-tech/auth/server";

relyingParty takes everything IssuerConfig does, so most products never call issuer directly. Two behaviours are worth knowing whichever way you reach them.

The origin the server uses can differ from the one the browser is sent to. In a cluster the browser goes to the public issuer and the server reaches http://keycloak:8080. internalOrigin rewrites the transport rather than the issuer, so discovery still validates the issuer in the document against the public URL — which is what Keycloak puts there. rewriteOrigin is that rewrite on its own, and when the two origins are equal it is a pass-through, which is why there is no second code path for the ordinary case.

Discovery is allowed to fail. Keycloak is frequently not up when the application is, and a library hiding a retry loop inside itself takes that decision away from the caller. A failed discovery is simply not cached, so the next call tries again — the retry is your await, on your schedule. Issuer.rediscover() throws away what was discovered, which is the answer to signing-key rotation, and it is deliberately reactive: the trigger is a verification that failed, never a timer. A timer refreshes when nothing is wrong and is still stale at the moment something is.

privateKey selects private_key_jwt over a shared clientSecret and wins when both are given: the secret never travels. allowInsecureHttp is what lets a compose file on a laptop serve http://localhost:8080.

API Reference

relyingParty

RelyingPartyConfig extends IssuerConfig with:

PropTypeDefault
secretstring | Uint8Array—
scopestringopenid profile email organization:* — whatever is given, its organization scope is organization:*, or the one alias a sign-in names
storeSessionStorestatelessStore()
maxAgenumbereight hours, in seconds
postLogoutRedirectUristring—

Returns a RelyingParty.

Token

What token answers for a live session: a Renewed with the credential on it.

FieldTypeDescription
endedfalse
accessTokenstringThe bearer credential for a resource server
sessionSessionWho it belongs to
cookiesreadonly string[]Set-Cookie values to attach. Empty unless the stateless store re-sealed — attaching them is not optional

token takes { renewWithin?: number }, seconds, default 60.

Ended

What token and refresh answer when there is no live session.

FieldTypeDescription
endedtrue
code"session/absent" | "token/refused"No session to renew, or the IdP refused to renew it
cookiesreadonly string[]The Set-Cookie that clears the browser's cookie; empty when it held none

SessionRecord

FieldTypeDescription
sessionSessionWhat the browser is allowed to see
sidstringThe ID token's sid, kept across refreshes, for a back-channel logout to name
accessTokenstringThe credential for a resource server
accessTokenExpiresAtnumberEpoch ms, from the response's expires_in rather than from opening the token
refreshTokenstringRotated on every use; the stored one is always the newest issued
idTokenstringKept for id_token_hint at the end-session endpoint

ticketStore

ticketStore(adapter: TicketAdapter, config?: TicketStoreConfig): SessionStore

TicketAdapter:

MethodSignature
read(key: string) => Promise<string | null>
write(key: string, value: string, ttl: number) => Promise<void>
replace(key: string, value: string, ttl: number) => Promise<boolean> — overwrite only if the key still exists, in one atomic step
delete(key: string) => Promise<void>
keys(prefix: string) => AsyncIterable<string> — optional; only dropAll needs it

Each call is bounded by the store at 30 s (session/silent), so an adapter races nothing itself.

TicketStoreConfig:

PropTypeDefault
ttlnumbereight hours, in seconds

IssuerConfig

PropTypeDefault
issuerstring—
clientIdstring—
clientSecretstring—
privateKeyCryptoKey | PrivateKey—
internalOriginstringthe issuer's own origin
fetchtypeof fetchthe global
allowInsecureHttpbooleanfalse
verifySignaturesbooleanwhether the token endpoint is reached over TLS

verifySignatures defaults to a derivation rather than to false, and the reason is worth the line: the specification lets a code grant skip ID-token signature verification because the TLS channel to the token endpoint vouches for it — and an internalOrigin of http://keycloak:8080 withdraws exactly that premise. The signature check follows the channel is the argument in full, with the clause it rests on and what would reverse it.

sealedCookie

SealedCookieConfig:

PropTypeDescription
namestringThe name after the __Host- prefix, which is added here
secretstring | Uint8ArrayAny length — generate it, do not choose it
maxAgenumberSeconds. Both the cookie's Max-Age and the JWE's exp

SealedCookie<T> carries name, seal(value), read(header) and clear(). read answers null for absent, tampered and expired alike — three ways of not having a session are one answer.

On this page