Keycloak
The realm this package expects — role claims on both tokens, an audience mapper naming your API, the organization scope with the star, an application's roles inside each organization, back-channel logout, and passkeys.
This is the page to hand to whoever runs the realm. Nothing here is invented: every claim the package reads is one Keycloak emits, and the configuration below is what makes it emit them on the token that needs them.
A realm already configured as this page describes ships beside the package, under the same tag: the identity service, which also has how to run it, register an application and check the contract on a live token.
One realm, one client per application
One realm for the whole platform, not one per product. Give each product its own realm and the same person becomes two different users with two different subjects, and every product that wants to know who they are has to reconcile them. Products are not tenants — they are OIDC clients of one realm. The tenants are the organizations.
So a client is an application: board, ledger, viewer. Not a tenant. A client per tenant is
how this was done before Organizations existed, and it makes onboarding a customer a deployment
instead of an operation.
An application registers itself from its own repository — its client, the audience its access token names, and its roles with their composites — and the realm knows no application by name. Register an application is the module that does it.
Role claims on both tokens
Roles must be on the access token so an API can authorize, and on the ID token so an interface can draw. This is the one setting most likely to be wrong, and both failures are quiet:
- roles on the ID token only — your API cannot authorize anything from the credential it was sent, so authorization moves somewhere it can be forged or somewhere it has to be looked up again;
- roles on the access token only — the browser has nothing to draw with, and the tempting fix is to open the access token in the client, which it must never do.
The role mappers are Keycloak's own and need no renaming. The package reads:
| Claim | Emitted by | Becomes |
|---|---|---|
sub, email, name (or given_name + family_name), preferred_username | the profile and email scopes | session.user |
realm_access.roles | the realm role mapper | session.roles |
resource_access.<clientId>.roles | the client role mapper | session.roles, unioned with the above |
organization | the organization scope's membership mapper, with addOrganizationId | session.organizations — alias and id |
organization.<alias>.resource_access.<clientId>.roles | the Organization Group Membership mapper, with addGroupRoleMappings | that organization's roles — never session.roles |
exp | the token | session.expiresAt, in milliseconds |
Do not rename a claim. A mapper with claim.name rewritten to something like app:role
renames what Keycloak already publishes for free, and a colon is not a JWT naming convention in the
first place. Every deployment that does it has bought itself a translation layer and lost the one
this package ships.
The audience mapper
The access token needs an audience mapper naming your API, because a resource server validates
aud before it trusts anything else in the token. Without one, the audience is whatever client
asked for the token — which works only while the relying party and the resource server are the same
process, and stops working the moment they are not.
Add an audience protocol mapper on the application's client, with the API's client id as the included audience, and Add to access token on.
The scope with the star
Ask for organization:*. The shape matters, and the three spellings do different things:
| Requested scope | What comes back |
|---|---|
organization:* | every organization the person belongs to |
organization:amber organization:salt | the two that were named |
organization | the only one when there is one — and a prompt to choose when there are several |
That last row is the whole reason the star is the default. It is the documented behaviour behind
more than one report of the organization claim "disappearing" for people who belong to several
organizations: it never disappeared. Keycloak stopped the flow on a "Select an organization to
proceed" page, issued no code at all, and a product that never shows a chooser saw nothing come
back.
Every sign-in through relyingParty requests openid profile email organization:*, whatever else a
product adds to scope. SignInOptions.organization narrows it to one, by replacement rather than
by appending — asking for both the star and a name would defeat the point of naming one.
Roles inside an organization
An application's roles in an organization arrive inside that organization's entry of the
organization claim, and one more mapper is what puts them there:
- The Organization Group Membership mapper (
oidc-organization-group-membership-mapper) on theorganizationclient scope, beside the membership mapper rather than replacing it — it adds to the entries the first one writes. A second membership mapper does not override the first; it collides with it, and the claim comes out with a serialised JSON string as an object key. addGroupRoleMappingson, which needs Keycloak 26.7 or later. Without it the entry carries the person's group names and no roles, and every organization reads as a membership with nothing in it.- On the access token and the ID token both, for the same reason as the role claims.
With that, each entry carries resource_access.<clientId>.roles: the client roles an organization's
administrator mapped onto the groups the person is in, composites expanded. The groups
themselves are the organization's to name — "Admins", "Analysts", whatever that customer calls its
people — and the package never reads a group's name. What grants a role is a role mapped onto a
group.
Two things sit on either side of that mapping. The hierarchy is the application's, declared as
composite roles in its registration (roles in services/auth/modules/app, at most three deep),
so a product asks whether editor is present and never ranks. The mapping is the
organization's: its administrator does it in the console, and the identity
service has it as a command and as the
admin API call — under the organization, because the realm's own /groups/{id}/role-mappings
answers 400 for an organization group, which was measured rather than assumed.
What the token actually carries
Organization Groups give every organization its own isolated hierarchy — which is exactly what
shared realm groups could not do — and one claim carries all of it, every organization and the
roles held in each. This is the organization claim on an access token from Keycloak 26.8.0, taken
off the identity service by scripts/verify.sh for the seeded ana through
the kanzo-conformance client, whose role high is a composite containing low:
"organization": {
"globex": {
"id": "94b69c97-37fa-4c06-be6f-d58cabe1ff28",
"groups": ["/Readers"],
"resource_access": { "kanzo-conformance": { "roles": ["low"] } }
},
"acme": {
"id": "3fe7a56b-557c-478c-8381-ad0b998181dc",
"groups": ["/Admins"],
"resource_access": { "kanzo-conformance": { "roles": ["high", "low"] } }
}
}acme mapped high onto its "Admins" and globex mapped low onto its "Readers"; ana is in one of
each. So acme's entry says high and low — the composite arrives expanded, and nobody ranked
anything — while the top-level resource_access of the same token carries no kanzo-conformance
roles at all: Keycloak keeps a role held in an organization inside that organization. The group
names are on the token and nothing reads them. This is a recorded token rather than an
illustration: packages/auth/src/claims.test.ts holds it and asserts each of those readings.
The id is the uuid and the key is the alias. Store the id: an alias can be renamed, and it is the
alias that appears in a hostname.
Reading it yourself
The reader is public and pure — a decoded claim set and a ClaimsConfig in, a Session out, with
no network, no storage and no React anywhere near it:
import { claims, type ClaimsConfig } from "@kanzo-tech/auth";
const session = claims(decoded, { clientId: "board" });The claim set
claims(token, { clientId: "board" })
- user
- Ravenna Sarkis · ravenna
- roles
- organizations · amber
- organizations · salt
- organizations · nine
- expiresAt
- 2099-01-01T00:00:00.000Z
| Hall | groups — never read | resource_access.board.roles |
|---|---|---|
| Amber Hall | /Wardens | |
| Salt | /Archive | |
| The Nine | /Outriders |
The claim set on the left is the same shape with the Guild in it — three halls, two applications
sharing them — and the Session on the right is what this package makes of it. The control changes
which application is asking. Read as board, Ravenna is a warden of the Amber Hall, an
archivist in Salt and a scout in the Nine; read as ledger, the same token makes her a reader of
one hall and nothing anywhere else. One token, two applications, and neither one's roles reaching
the other — which is the reason the reader takes a client id at all: without it, a role granted in
the ledger would authorise its holder on the board.
The table underneath is each hall's entry: the groups the token carries, drawn and never read, and the roles read for the asking client. Membership survives the switch; the roles inside it do not.
Both doors run this one function, which is why a claim reaches a browser session and a server
session as the same thing. It throws only for a claim set with no sub — not a session at all, but
a misconfiguration worth being loud about. Everything else degrades quietly to empty: holding no
roles and belonging to no organization are legitimate states, and a token that merely omits a scope
must not take the application down.
A person who belongs to no organization gets no organization claim at all. Keycloak drops
organization:* from the granted scope when there is nothing to fill it, so a single-tenant
product asking for the star pays exactly nothing: the claim is absent, session.organizations is
[], and the same code runs. Multi-tenant and single-tenant are not two code paths here — one of
them just has an empty list.
What your API must validate
The package obtains the credential, attaches it, and defines the claim model. Validating it is the resource server's job, and yours are probably not written in TypeScript — so this is the contract in words rather than a module, and this page is the only place it is agreed.
Validate, and refuse the request if any of it fails:
iss | exactly your realm's issuer |
aud | contains your API's client id — the audience mapper above is what puts it there |
exp | not past |
| signature | against the realm's JWKS, with the key fetched by kid and cached |
Then authorize on:
resource_access.<clientId>.roles | what this person may do in this application, anywhere |
organization.<alias>.resource_access.<clientId>.roles | what they may do in this application inside that organization — the keys of organization are which ones they belong to |
And one rule that belongs beside them, because it is the line between a multi-tenant product and a
leak between customers: the host is a hint and the token is the authority — the same rule
can follows for the current organization, stated in full on
Roles.
Resolve the hint against the organization claim, refuse on a miss, and do it before anything
opens a database.
Back-channel logout
A session can end at Keycloak without the browser being anywhere near the application: an
administrator signs the person out, the SSO session expires, they sign out of another application.
OpenID Connect Back-Channel Logout 1.0 is how the application hears about it — Keycloak posts a
signed logout token to the client's back-channel logout URL, and kanzoAuth serves that at
<basePath>/backchannel-logout, dropping the sessions it
names so the next navigation signs in.
services/auth/modules/app takes it as backchannel_logout_url, and leaves
backchannel_logout_session_required on so the token carries sid and one browser session can end
without the person's others:
module "app" {
# …
backchannel_logout_url = "http://board:3000/api/auth/backchannel-logout"
}Two properties of the setting decide how it is used:
- Keycloak calls it, server to server. It is a URL Keycloak can reach, which inside a cluster or a compose network is an internal hostname, not the public one the browser uses.
- It is one URL per client. A client shared by several deployments — one per tenant behind one
client_id— can name only one of them, so the others never hear a logout. Back-channel logout wants one client per deployment that holds sessions.
It also needs a session store that can find a person's sessions: a ticketStore whose adapter has
keys. With the stateless store, or without keys, the route answers 501, and Keycloak's admin
console reports the logout as failed rather than believing it happened.
Passkeys
Passkeys are Keycloak's since 26.4, and turning them on is one switch on the realm: Enable Passkeys, in the WebAuthn Passwordless Policy. Keycloak then puts them into the forms it already draws — no flow of your own, no theme, and nothing in this package, because the sign-in is Keycloak's page.
What a person sees, with the realm's default browser flow and organizations on:
- The username step tags its input
autocomplete="username webauthn", so the browser offers the passkeys it holds for the site as autofill, and adds a Sign in with Passkey button for the ones it cannot list — a security key, another device. - A passkey ends the sign-in there. The password step sees a person already authenticated by a passwordless credential and asks for nothing, and the realm's conditional two-factor is skipped for the same reason.
- The password still works, exactly as before. Passkeys are offered, never required.
A person adds one from the account console, Signing in → Passkeys —
accountUrl(issuer, "account-security/signing-in"), below —
through the webauthn-register-passwordless required action, which Keycloak enables in every realm.
In Terraform the switch is passwordless_passkeys_enabled in the realm's
web_authn_passwordless_policy, and the block wants two more lines than it looks like it should:
web_authn_passwordless_policy {
passwordless_passkeys_enabled = true
relying_party_entity_name = "Kanzo"
user_verification_requirement = "required"
discoverable_credential = "required"
}The provider sends every field of the block, and for one left out it sends not specified,
not Keycloak's own default. A passkey is a discoverable credential with user verification: without
those two lines the realm may register a key autofill cannot list, or one that signs in without
proving who holds it. Passkey Mediation — whether the page opens a passkey dialog on load — is
not in the provider, and Keycloak's default, conditional (autofill only, no dialog), is the one
wanted.
The account console is Keycloak's
A person's password, two-factor methods and sessions on each device are Keycloak's account console,
and a product links to it rather than rebuilding it. accountUrl derives the link from the public
issuer you already configured:
import { accountUrl } from "@kanzo-tech/auth";
accountUrl(issuer); // https://id.example/realms/kanzo/account
accountUrl(issuer, "account-security/signing-in"); // password and two-factor
accountUrl(issuer, "account-security/device-activity"); // sessions on each deviceAccountPage is the routes the console (account-ui, Keycloak 26) declares that a product has a
reason to link: "" (personal info), "account-security/signing-in" and
"account-security/device-activity". It is pure and on the root barrel, so a server component and
a SPA's menu draw the same link.
Onboarding a customer
With Organizations, creating a customer is an operation rather than a deployment: create the organization, invite the people, done. There is no tenant table anywhere — existing means existing in Keycloak — and there is no client, no pair of roles and no set of protocol mappers to provision per customer.
What the organization does for itself is arrange its people into groups and map the application's roles onto them, as above. What would end that is needing something per organization Keycloak cannot hold. It holds attributes, so nothing has yet.
Roles
can, Gate and useSession().can — asking a role question inside the current organization, where the roles in it are read from, and why the predicate has no hierarchy.
The identity service
The server half of @kanzo-tech/auth — Keycloak with the platform realm as code, a module an application registers itself with, and a script that checks the claim contract on a live token.