@ricardoqmd/auth-vue
v0.4.1
Published
Vue 3 client-side bindings for @ricardoqmd/auth-core — createAuth plugin, useAuth composable, RBAC helpers
Downloads
37
Maintainers
Readme
@ricardoqmd/auth-vue
Vue 3 client-side bindings for
@ricardoqmd/auth-core. AcreateAuthplugin and a reactiveuseAuth()composable with RBAC helpers.
Installation
npm install @ricardoqmd/auth-core @ricardoqmd/auth-keycloak @ricardoqmd/auth-vue vue keycloak-js xstateInstall
xstateeven though you never import it:@ricardoqmd/auth-coreand@xstate/vuedeclare it as a peer, and a single shared instance must resolve at the top level (a duplicate copy would break the actor's reactivity).
Scope is SPA / client-only (ADR-012). The plugin eagerly initializes the auth flow on install, which assumes a browser. It is SSR-ready by construction (one actor per app instance, never a module-level singleton) but SSR is not a supported target in v0.x.
Usage
1. Create the provider
Create the Keycloak provider outside the plugin call so the instance is
created once. The binding is IDP-agnostic — pass any AuthProvider.
// src/auth.ts
import { createKeycloakProvider } from "@ricardoqmd/auth-keycloak";
export const provider = createKeycloakProvider({
config: {
url: import.meta.env.VITE_KC_URL,
realm: import.meta.env.VITE_KC_REALM,
clientId: import.meta.env.VITE_KC_CLIENT_ID,
},
onLoad: "check-sso",
});
check-ssoboots the app without forcing a login — anonymous users land on your UI and sign in on demand. Uselogin-requiredinstead to redirect straight to Keycloak before the app renders.
2. Install the plugin
// src/main.ts
import { createApp } from "vue";
import { createAuth } from "@ricardoqmd/auth-vue";
import App from "./App.vue";
import { provider } from "./auth";
createApp(App).use(createAuth({ provider })).mount("#app");3. Use auth state in any component
useAuth() returns reactive state. The state values are ComputedRefs — read
them with .value in <script setup>; in the template they are auto-unwrapped,
so you can write isAuthenticated directly without .value.
<script setup lang="ts">
import { useAuth } from "@ricardoqmd/auth-vue";
import type { KeycloakIdpClaims } from "@ricardoqmd/auth-keycloak";
const { user, isAuthenticated, logout, hasRole, hasAnyRole } =
useAuth<KeycloakIdpClaims>();
// In script context, ComputedRefs need .value:
function reportAdmin() {
console.log("is admin?", hasRole("admin"));
console.log("authenticated?", isAuthenticated.value);
}
</script>
<template>
<!-- In templates, refs are auto-unwrapped (no .value): -->
<p>Welcome, {{ user?.preferred_username }}</p>
<button v-if="hasRole('admin')">Admin panel</button>
<button v-if="hasAnyRole(['editor', 'admin'])">Edit</button>
<button @click="logout">Sign out</button>
</template>useAuth<TIdpClaims>() is generic over the IDP claims shape. Pass your adapter's
claims interface (e.g. KeycloakIdpClaims) for typed access to idpClaims.
hasRole() / hasAnyRole() are the universal role checks, backed by
user.roles. With Keycloak that array is mapped from realm roles only, so
hasRole("capturista") returns false for a role granted per client — in a
multi-app SSO the functional role usually lives in resource_access[clientId]. For
client roles use the adapter's bound accessors provider.hasClientRole(claims, role)
/ provider.clientRoles(claims), or the standalone hasResourceRole / resourceRoles
from @ricardoqmd/auth-keycloak. See the adapter's
Roles section.
Sign-in on demand (check-sso flows)
When the provider is configured for check-sso, the app starts unauthenticated.
Call login() from the composable to start the redirect:
<script setup lang="ts">
import { useAuth } from "@ricardoqmd/auth-vue";
const { isAuthenticated, login, logout } = useAuth();
</script>
<template>
<button v-if="isAuthenticated" @click="logout">Sign out</button>
<button v-else @click="login">Sign in</button>
</template>Gating the app while auth settles
There is no built-in AuthGate component in v0.x. Gate at the root with v-if
on isLoading and error, then render your app once auth is settled:
<script setup lang="ts">
import { useAuth } from "@ricardoqmd/auth-vue";
const { isLoading, error, isAuthenticated } = useAuth();
</script>
<template>
<p v-if="isLoading">Signing in…</p>
<p v-else-if="error">Authentication failed: {{ error.message }}</p>
<RouterView v-else-if="isAuthenticated" />
<LoginScreen v-else />
</template>Route guards
For the common case, createAuthGuard(router, { auth, provider, isPublic?, oidcParams? })
registers a single global beforeEach that handles the universal parts of an OIDC SPA login
for you, so you don't hand-roll them:
- awaits
auth.whenReady()— no decision races a pendinginit(); - strips the OIDC callback params (
?code,state,session_state,iss) from the URL by navigating through the router; - redirects to
provider.login()when there is no session, unlessisPublic(to).
Why strip through the router. After Keycloak login, keycloak-js cleans the OIDC
callback params from the URL — but vue-router can repaint the pre-clean URL on its
next navigation, leaving ?state=…&code=… lingering in the address bar. Returning a
replace: true route from the guard makes the router perform the cleanup itself, so
there is no race. See ADR-015.
No RBAC in the guard. Role policy — which roles gate which routes, and where a
"forbidden" screen lives — is an app convention, so the guard deliberately leaves it
out. The supported pattern is to compose: createAuthGuard for the universal parts,
then your own beforeEach for roles.
// main.ts
import { createApp } from "vue";
import { createAuth, createAuthGuard } from "@ricardoqmd/auth-vue";
import { createKeycloakProvider } from "@ricardoqmd/auth-keycloak";
import { router } from "./router";
import App from "./App.vue";
const provider = createKeycloakProvider({ /* ... */ });
const auth = createAuth({ provider });
const app = createApp(App);
app.use(auth);
app.use(router);
// Universal OIDC handling: init race, URL cleanup, login redirect.
createAuthGuard(router, { auth, provider, isPublic: (to) => to.meta.public === true });
// Consumer's own RBAC guard (app convention: meta.roles + a forbidden route).
router.beforeEach((to) => {
const roles = to.meta.roles as string[] | undefined;
if (roles?.length && !auth.hasAnyRole(roles)) return { name: "forbidden" };
return true;
});
app.mount("#app");The imperative handle
useAuth() is for components — it is reactive and throws outside setup(). For
code that runs outside the render tree (a custom guard, an HTTP interceptor), use the
value returned by createAuth(...): it is BOTH a Vue plugin AND an imperative
AuthHandle. The same object you install with app.use() exposes synchronous
accessors: isAuthenticated(), isLoading(), getToken(), getUser(),
getIdpClaims(), getError(), hasRole(), hasAnyRole(), whenReady(), and
subscribe(). auth.whenReady() resolves once init() settles (authenticated,
unauthenticated, or error), so the first navigation does not race a pending
initialization. Use useAuth() in components (reactive); use the handle in
guards/interceptors (imperative).
Composing your own guard with stripOidcParams
If you'd rather not use createAuthGuard, compose the handle yourself — but you must
still clean the OIDC callback params from the URL, or ?code=…&state=… lingers in the
address bar (the bug ADR-015 fixed). The URL-cleaning primitive is exported on its own:
stripOidcParams(to, params?) returns a cleaned RouteLocationRaw (replace: true,
same path/hash, OIDC params removed) or null when there is nothing to strip:
import { stripOidcParams } from "@ricardoqmd/auth-vue";
router.beforeEach(async (to) => {
await auth.whenReady(); // wait out the first-navigation init race
const cleaned = stripOidcParams(to); // strip ?code/state/… — do NOT skip this step
if (cleaned) return cleaned;
if (to.meta.requiresAuth && !auth.isAuthenticated()) {
await provider.login();
return false;
}
if (to.meta.roles && !auth.hasAnyRole(to.meta.roles as string[])) {
return { name: "forbidden" };
}
return true;
});Pass a second argument to override the stripped set (defaults to code, state,
session_state, iss), e.g. stripOidcParams(to, ["ticket"]).
vue-routeris an optional peer — it is only needed if you use the guard (orstripOidcParams). Install it alongside the other peers when you do:npm install @ricardoqmd/auth-core @ricardoqmd/auth-keycloak @ricardoqmd/auth-vue vue keycloak-js xstate vue-router
Getting a token for API requests
There are two ways to read a token, and only one is correct for a request.
token(fromuseAuth(), and the syncgetToken()on the handle) — a snapshot. For display, debugging, and UI gates only.acquireToken(minValidity?)— a promise resolving a token valid for at leastminValidityseconds (default 30) when the IdP can grant it, refreshing first if needed. This is the only correct way to attach a token to a request. (Near the session's maximum lifetime the remaining session time caps the token's expiry; the freshest obtainable token is returned then, notnull— the session is still alive.)
Why: a snapshot captured inside a long-lived closure (an axios interceptor, an event
handler) pins a string that goes stale, producing 401s that depend on how long the
closure has been alive. And the proactive refresh cannot save you — it is a
setTimeout, and browsers throttle timers in hidden tabs and pause them during system
sleep, so on tab return the machine can still hold an expired token. The proactive
refresh is a latency optimization; acquireToken() is the correctness guarantee.
acquireToken is available both on useAuth() (inside components) and on the object
returned by createAuth() (outside the component tree — which is what an interceptor
needs).
axios request interceptor
The interceptor lives outside the component tree, so it uses the handle from
createAuth() directly:
// src/http.ts
import axios from "axios";
import { auth } from "./auth"; // the value returned by createAuth({ provider })
export const http = axios.create({ baseURL: "/api" });
http.interceptors.request.use(async (config) => {
// Acquired at REQUEST time, not at module load — never a stale capture.
const token = await auth.acquireToken();
if (token === null) {
// Session is over. Re-authenticate — never retry with the old token.
return Promise.reject(new Error("Session expired"));
}
config.headers.Authorization = `Bearer ${token}`;
return config;
});// src/auth.ts
import { createAuth } from "@ricardoqmd/auth-vue";
import { createKeycloakProvider } from "@ricardoqmd/auth-keycloak";
export const provider = createKeycloakProvider({ /* ... */ });
export const auth = createAuth({ provider }); // Plugin & AuthHandleInside a component, the same thing is on the composable:
<script setup lang="ts">
import { useAuth } from "@ricardoqmd/auth-vue";
const { acquireToken } = useAuth();
async function loadThings() {
const token = await acquireToken();
if (token === null) return; // session over — re-authenticate
await fetch("/api/things", { headers: { Authorization: `Bearer ${token}` } });
}
</script>acquireToken() resolves null when the session is over (unauthenticated, refresh
failed, or error). Treat null as "re-authenticate" — never retry the request with a
previously held token. Concurrent callers share a single refresh, so a burst of requests
produces one refresh, not one per request.
The plugin also refreshes on tab return (
visibilitychange) when the token is close to expiry. That is a latency optimization so the user's first interaction after coming back is not blocked on a refresh — it does not change the rule above.
API
createAuth(options)
Returns a value that is BOTH a Vue Plugin AND an AuthHandle<TIdpClaims>.
Install it with app.use(createAuth({ provider })); the same object is usable
imperatively outside components (see Route guards).
Creates one auth actor per call, starts it, sends INIT, and provides it
app-wide.
| Option | Type | Description |
|---|---|---|
| provider | AuthProvider<TIdpClaims> | Adapter instance from createKeycloakProvider() (or any IDP adapter). Create it once, outside the plugin call. |
useAuth<TIdpClaims>()
Must be called in setup() of a component whose app installed createAuth().
Throws otherwise. Returns an AuthState<TIdpClaims>:
| Field | Type | Description |
|---|---|---|
| isLoading | ComputedRef<boolean> | True during initializing or loggingOut |
| isAuthenticated | ComputedRef<boolean> | True when the machine is in the authenticated state |
| token | ComputedRef<string \| null> | Raw JWT access token — a snapshot for display/UI gates only. Never capture it in a long-lived closure; see Getting a token for API requests |
| acquireToken | (minValiditySeconds?: number) => Promise<string \| null> | Resolves a token valid for at least minValiditySeconds (default 30) when the IdP can grant it, refreshing on demand; near the session's maximum lifetime the remaining session time caps the token, in which case the freshest obtainable token is returned. The only correct way to get a token for a request. null = session over → re-authenticate |
| user | ComputedRef<AuthUserClaims \| null> | Decoded standard OIDC claims (preferred_username, email, name, sub, roles, …) |
| idpClaims | ComputedRef<TIdpClaims \| null> | IDP-specific token claims; pass your IDP's claims interface to useAuth<T>() |
| error | ComputedRef<AuthError \| null> | Structured error set in the error state; branch on error.code |
| login | () => void | Starts the login redirect (useful with check-sso) |
| logout | () => void | Triggers the logout flow |
| hasRole | (role: string) => boolean | True if user.roles includes role |
| hasAnyRole | (roles: string[]) => boolean | True if the user has at least one of the given roles |
createAuthGuard(router, options)
Registers a global beforeEach on router (see Route guards).
Returns nothing. Requires vue-router (optional peer).
| Option | Type | Default | Description |
|---|---|---|---|
| auth | AuthHandle<TIdpClaims> | required | The value returned by createAuth(). |
| provider | AuthProvider<TIdpClaims> | required | Same adapter passed to createAuth(); its login() drives the redirect. |
| isPublic | (to: RouteLocationNormalized) => boolean | nothing public | Marks routes reachable without a session. |
| oidcParams | readonly string[] | ["code", "state", "session_state", "iss"] | Override the stripped OIDC param set. |
stripOidcParams(to, params?)
Pure primitive. Returns a cleaned RouteLocationRaw (replace: true, same path/hash,
OIDC params removed) when to carries any of params, else null. params defaults to
["code", "state", "session_state", "iss"]. Requires vue-router (optional peer) for its
types.
Handling errors
error is a structured AuthError from @ricardoqmd/auth-core, not a plain
Error. Branch on error.code to drive UX. code is one of INIT_FAILED,
REFRESH_FAILED, TOKEN_EXPIRED, or NETWORK_ERROR. New codes may be added
over time, so always handle default. (TOKEN_EXPIRED is reserved and not
currently emitted by auth-keycloak — a dead refresh token rejects as
REFRESH_FAILED; see ADR-009.)
Status
Pre-1.0. The public API mirrors @ricardoqmd/auth-nextjs and shares the
@ricardoqmd/auth-core contract.
@ricardoqmd/auth-vue is 0.x and versions independently from
@ricardoqmd/auth-core and @ricardoqmd/auth-keycloak (ADR-013): its version
does not track theirs, and the API is not frozen until 1.0.
License
MIT © ricardoqmd
