@ricardoqmd/auth-keycloak
v1.1.0
Published
Keycloak adapter for @ricardoqmd/auth-core — implements the AuthProvider contract using keycloak-js
Maintainers
Readme
@ricardoqmd/auth-keycloak
Keycloak adapter for
@ricardoqmd/auth-core. Wrapskeycloak-jsto implement theAuthProvidercontract.
Install
npm install @ricardoqmd/auth-keycloak keycloak-js @ricardoqmd/auth-corekeycloak-js is a peer dependency — install it explicitly so you control its version.
What's in the box
createKeycloakProvider()— factory that returns aKeycloakProvider(a superset ofAuthProvider<KeycloakIdpClaims>) ready to plug into a framework binding. Its bound accessorshasClientRole()/clientRoles()check client roles against the configuredclientId.KeycloakProvider/KeycloakIdpClaims— TypeScript interfaces for the returned provider and the Keycloak-specific token claims (realm_access,resource_access).hasResourceRole()/resourceRoles()— standalone predicate and extractor for client-level (resource) roles on an arbitrary client. Use these when the universalhasRole()from your binding is not enough. See Roles.
Quick start
If you are using Next.js, see @ricardoqmd/auth-nextjs for end-to-end setup. The snippet below shows the adapter in isolation:
import { createKeycloakProvider } from "@ricardoqmd/auth-keycloak";
const provider = createKeycloakProvider({
config: {
url: "https://keycloak.example.com",
realm: "my-realm",
clientId: "my-app",
},
});
// `provider` implements AuthProvider<KeycloakIdpClaims>
// Pass it to <AuthProvider> from @ricardoqmd/auth-nextjs (or any future binding)Configuration options
createKeycloakProvider({
config: {
url: string; // Keycloak server URL
realm: string; // realm name
clientId: string; // OAuth client ID
},
onLoad?: "login-required" | "check-sso"; // default: "login-required"
checkLoginIframe?: boolean; // default: false
pkceMethod?: "S256"; // default: "S256"
responseMode?: "fragment" | "query"; // default: keycloak-js default ("fragment")
silentCheckSsoRedirectUri?: string;
logoutRedirectUri?: string;
});Roles
Keycloak has two kinds of roles, and the distinction is the single most common source of "why does my role check return false?" (ADR-018).
hasRole() / hasAnyRole() are realm roles only
The universal hasRole() / hasAnyRole() on your binding's useAuth() read
user.roles, which this adapter maps from realm_access only. They live on the
AuthHandle in @ricardoqmd/auth-core, which is IdP-agnostic and frozen (ADR-004,
ADR-009): "realm" and "resource_access" are Keycloak vocabulary and cannot leak into
the core contract. So their meaning is fixed — realm roles — and cannot be widened.
The trap: the functional role is usually a client role
In a department-wide SSO (one realm, many apps), realm roles are typically global
access markers (kronia-app), while the functional role inside an app is
granted per client and lives in resource_access[clientId].roles (administrador,
capturista). That means:
const { hasRole } = useAuth();
hasRole("capturista"); // → false, SILENTLY — "capturista" is a *client* roleNo error is thrown; the check just answers false. If your app's roles are client
roles, use the accessors below instead.
Client-role accessors
createKeycloakProvider() returns a KeycloakProvider — a superset of the
AuthProvider port — with two accessors bound to this provider's own
config.clientId, so you never repeat the client id and can't drift onto the wrong
client:
provider.hasClientRole(claims, "capturista"); // boolean, on THIS client
provider.clientRoles(claims); // string[] of roles on THIS clientFor a different client, use the standalone utilities (they take the resource id explicitly):
import { hasResourceRole, resourceRoles } from "@ricardoqmd/auth-keycloak";
hasResourceRole(claims, "billing-app", "auditor"); // boolean, on another client
resourceRoles(claims, "billing-app"); // string[] on another clientBoth accessors and utilities take claims as an argument rather than reading the
Keycloak instance internally. That is deliberate: reading kc.tokenParsed inside the
provider is an untracked read, so a computed / useMemo would not re-evaluate when
the token refreshes and a revoked role would keep rendering. Passing the reactive
idpClaims makes the dependency explicit.
Reactive (Vue computed):
<script setup lang="ts">
import { computed } from "vue";
import { useAuth } from "@ricardoqmd/auth-vue";
import type { KeycloakIdpClaims } from "@ricardoqmd/auth-keycloak";
import { provider } from "./auth";
const { idpClaims } = useAuth<KeycloakIdpClaims>();
const canCapture = computed(() => provider.hasClientRole(idpClaims.value, "capturista"));
</script>
<template>
<button v-if="canCapture">Capturar</button>
</template>Imperative (route guard / interceptor):
router.beforeEach((to) => {
if (to.meta.requiresCapture && !provider.hasClientRole(auth.getIdpClaims(), "capturista")) {
return { name: "forbidden" };
}
return true;
});Need the list — to display roles, or to assemble subjectAttributes.roles for a
policy decision point? Use the extractor: provider.clientRoles(claims) (bound) or
resourceRoles(claims, resource) (any client).
Freshness and enforcement
Since ADR-017, claims are re-normalized on every token refresh, so role changes propagate within the token lifecycle — a revoked role disappears at the next refresh without a full page reload (bounded by the access token's remaining lifetime, not immediate).
SPA role checks are advisory, never an enforcement boundary. The SPA is a public client and its JavaScript is user-modifiable; these checks make the UI accurate (stop offering actions the backend will reject), not secure. Enforcement is the policy enforcement point (PEP) validating the token server-side.
Deployment notes
- Secure context required.
keycloak-jsuses the Web Crypto API for PKCE (S256), which browsers expose only in a secure context — HTTPS orlocalhost. Served over plainhttp://<ip>, init fails withINIT_FAILED("Web Crypto API is not available"). - HTML5-history routers and
responseMode. keycloak-js defaults toresponseMode: "fragment", so the OIDC callback returns in the URL fragment (#state=...&code=...). Apps using an HTML5-history router (e.g. vue-router'screateWebHistory) don't manage the fragment, so it lingers in the address bar after login. SetresponseMode: "query"to receive the callback as?code=...instead — both keycloak-js and HTML5 routers strip query params from the URL reliably. Default behavior is unchanged when the option is omitted. - Silent check-sso and CSP. Setting
silentCheckSsoRedirectUriloads Keycloak in a hidden iframe; Keycloak's defaultframe-ancestors 'self'CSP blocks that cross-origin until you allow the app's origin (Realm Settings → Security Defenses → Content-Security-Policy). Redirect-based flows (login-required, orcheck-ssowithoutsilentCheckSsoRedirectUri) avoid the iframe entirely.
Compatibility
| Package version | keycloak-js | Keycloak server | | --------------- | ------------- | --------------- | | 1.x | >=26.0 <28.0 | >=26.0 |
Since Keycloak 26.2, the keycloak-js adapter is released independently from the server and is backwards compatible with all actively supported Keycloak server versions.
Status
Stable. The public API is frozen and follows SemVer from 1.0 onward (see ADR-009): additive changes are non-breaking; removing or renaming an export is a major bump.
License
MIT © ricardoqmd
