Logging in with OpenID Connect (Keycloak)¶
How a browser gets an identity from an OpenID Connect provider, and how the server checks it.
When to use this¶
When your application has real users. If it has none, you do not need this at all — see client identity without a login, which gives an open application per-browser state with no provider involved.
The shape of it¶
browser ──▶ Keycloak login ──▶ access token ──▶ WebSocket handshake ──▶ AuthenticatedPrincipal
authorization code + PKCE OidcAuthenticationProvider verifies it
Two halves, and you need both:
- In the browser,
OidcClientruns the authorization-code flow with PKCE and holds the token. - On the server,
OidcAuthenticationProviderverifies that token at the handshake and maps its claims to a name, roles and a tenant.
Server side¶
Add the module:
<dependency>
<groupId>com.zeroz4j</groupId>
<artifactId>zerozstack-auth-oidc</artifactId>
</dependency>
Register the provider in
src/main/resources/META-INF/services/com.zeroz4j.server.AuthenticationProvider:
Registering any provider disables the demo/admin development fallback entirely.
Configure it:
| Property | Meaning |
|---|---|
zeroz.oidc.issuer |
Required. The realm URL exactly as it appears in your tokens' iss claim. |
zeroz.oidc.jwksUri |
Where signing keys are published. Defaults to Keycloak's <issuer>/protocol/openid-connect/certs. |
zeroz.oidc.clientId |
This application's client id. Used to read client roles, and as the expected audience. |
zeroz.oidc.audience |
The aud a token must carry; defaults to clientId. * accepts any — only when something else constrains who the token was minted for. |
zeroz.oidc.principalClaim |
Which claim becomes the user name; default preferred_username, falling back to sub. |
zeroz.oidc.rolesClaim |
A flat roles claim, for providers that publish them that way. Unset reads Keycloak's structure. |
zeroz.oidc.tenantClaim |
Which claim carries the tenant. Unset means single-tenant. |
zeroz.oidc.tenantFromRealm |
true to use the realm name from the issuer URL as the tenant. |
zeroz.oidc.clockSkewSeconds |
Tolerance for exp/nbf; default 60. |
Roles¶
Keycloak does not publish a flat roles claim. It splits them, and both are read and merged:
realm_access.roles— realm rolesresource_access.<clientId>.roles— roles for this client only
Roles granted for a different client are ignored. These become the roles that @RolesAllowed,
@ClientWritable and @RequiresRole check.
Tenants¶
Two deployment shapes, both supported:
- One realm, a claim per user — set
zeroz.oidc.tenantClaim=tenantand add that claim to the token in Keycloak. Simplest to operate. - A realm per customer — set
zeroz.oidc.tenantFromRealm=trueand the tenant is the realm name from the issuer URL. Stronger isolation; realm provisioning becomes part of onboarding.
Neither set means single-tenant: the principal reports no tenant, so no Scope.TENANT push can reach
it.
Browser side¶
Register the application in Keycloak as a public client with Standard flow enabled and PKCE method S256 required, and add your application's URL as a valid redirect URI and web origin.
public static void main(String[] args) {
OidcClient.start(
new OidcClient.Config("https://keycloak.example.com/realms/acme", "zeroz-app"),
() -> {
Zeroz4jClient.connect(OidcClient.appendToken(wsUrl), () -> Router.start("app-root"));
});
}
start works out for itself whether this page load is a fresh visit, a return from Keycloak, or an
already-authenticated reload. Only the last two reach the callback; the first navigates away and the
callback runs on the way back instead.
appendToken puts the token on the WebSocket URL and installs a provider so every reconnect
picks up whichever token is current by then.
Logging out clears the local session and ends the provider's:
Expiry and reconnection¶
Identity on a zeroz4j connection is fixed when the socket opens, so a token expiring later does not
interrupt anything in flight. It matters on reconnect, which uses whatever token is current then.
OidcClient therefore refreshes silently ahead of expiry rather than on demand — the reconnect path
needs a token synchronously and has no point at which it could wait for a round trip. A refresh that
fails sends the user back to log in.
What this protects, and what it does not¶
- PKCE means an intercepted authorization code is worth nothing without the verifier that only
this browser holds. The
plainmethod is never used; if the browser cannot compute a SHA-256 challenge — Web Crypto needs a secure context — the login refuses rather than downgrading. - The
stateparameter is generated per attempt and checked on return, so a code delivered by a page the user did not start the login from is discarded. - The authorization code is stripped from the address bar as soon as it is used, keeping it out of history and bookmarks.
- The access token lives in
sessionStorage, which page script can read. That is unavoidable for a browser client — the token has to be sent from script.sessionStoragerather thanlocalStoragemeans it dies with the tab. It is also why the client-id cookie is kept separate andHttpOnlyrather than folded in here. - The token travels as a query parameter on the WebSocket URL, because a browser cannot set headers on an upgrade. Do not log full request URLs on the server or in a proxy in front of it.
- A rejected token is refused, never partially trusted. Signature, issuer, audience and expiry
are all checked; failure leaves the connection anonymous and every
@Securedcall on it fails.
Verifying it¶
The module's tests mint tokens locally and cover the refusals that matter — expired, wrong issuer,
wrong audience, wrong signing key, tampered payload, and an unsigned alg: none token. Run them with:
For an end-to-end check against a real provider, start Keycloak, create the realm and public client
above, and point both the server properties and OidcClient.Config at it.
See also¶
- Authentication and authorization — the provider SPI, client identity, origin checks
- Signals — what a tenant is for