Vue.js Integration
Vue.js Integration
This guide covers how to integrate Skycloak authentication into Vue.js applications using modern OAuth2/OIDC libraries and Vue best practices.
Prerequisites
- Vue.js 3+ application (Vue 2 compatible with adjustments)
- Node.js 14+
- Skycloak cluster with configured realm and client
- Basic understanding of Vue.js composition API and reactivity
Quick Start
1. Install Dependencies
npm install @vue-keycloak/keycloak keycloak-js
# or
npm install oidc-client-tsFor Vue 3 with TypeScript:
npm install @vue-keycloak/keycloak keycloak-js @types/keycloak-js2. Configure Keycloak Plugin
Create src/plugins/keycloak.js:
import Keycloak from 'keycloak-js';
const keycloakConfig = {
url: 'https://your-cluster-id.app.skycloak.io',
realm: 'your-realm',
clientId: 'your-vue-app',
};
const keycloak = new Keycloak(keycloakConfig);
export default keycloak;3. Setup Vue Plugin
Create src/plugins/vue-keycloak.js:
import { reactive, readonly } from 'vue';
import keycloak from './keycloak';
const state = reactive({
isAuthenticated: false,
user: null,
token: null,
refreshToken: null,
idToken: null,
roles: [],
loading: true,
error: null,
});
const keycloakPlugin = {
install(app, options = {}) {
const {
initOptions = {
onLoad: 'check-sso',
checkLoginIframe: false,
silentCheckSsoRedirectUri: window.location.origin + '/silent-check-sso.html',
},
onReady = () => {},
onInitError = () => {},
} = options;
// Initialize Keycloak
keycloak
.init(initOptions)
.then((authenticated) => {
state.isAuthenticated = authenticated;
state.loading = false;
if (authenticated) {
updateState();
setupTokenRefresh();
}
onReady(keycloak);
})
.catch((error) => {
state.error = error;
state.loading = false;
onInitError(error);
});
// Update state from Keycloak
const updateState = () => {
state.token = keycloak.token;
state.refreshToken = keycloak.refreshToken;
state.idToken = keycloak.idToken;
state.user = keycloak.tokenParsed;
state.roles = extractRoles();
};
// Extract roles from token
const extractRoles = () => {
const realmRoles = keycloak.tokenParsed?.realm_access?.roles || [];
const clientRoles =
keycloak.tokenParsed?.resource_access?.[keycloakConfig.clientId]?.roles || [];
return [...realmRoles, ...clientRoles];
};
// Setup automatic token refresh
const setupTokenRefresh = () => {
setInterval(() => {
keycloak
.updateToken(30)
.then((refreshed) => {
if (refreshed) {
updateState();
console.log('Token refreshed');
}
})
.catch(() => {
console.error('Failed to refresh token');
state.isAuthenticated = false;
});
}, 60000); // Check every minute
};
// Keycloak methods
const login = (options = {}) => {
return keycloak.login(options);
};
const logout = (options = {}) => {
return keycloak.logout(options);
};
const register = (options = {}) => {
return keycloak.register(options);
};
const hasRole = (role) => {
return state.roles.includes(role);
};
const hasAnyRole = (...roles) => {
return roles.some((role) => hasRole(role));
};
const hasAllRoles = (...roles) => {
return roles.every((role) => hasRole(role));
};
const getToken = async () => {
try {
await keycloak.updateToken(5);
return keycloak.token;
} catch (error) {
console.error('Failed to get token:', error);
throw error;
}
};
// Provide globally
app.config.globalProperties.$keycloak = keycloak;
app.config.globalProperties.$auth = {
state: readonly(state),
login,
logout,
register,
hasRole,
hasAnyRole,
hasAllRoles,
getToken,
};
// Provide for composition API
app.provide('keycloak', keycloak);
app.provide('auth', {
state: readonly(state),
login,
logout,
register,
hasRole,
hasAnyRole,
hasAllRoles,
getToken,
});
},
};
export default keycloakPlugin;4. Configure Main App
// main.js
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import keycloakPlugin from './plugins/vue-keycloak';
const app = createApp(App);
app.use(keycloakPlugin, {
initOptions: {
onLoad: 'check-sso',
silentCheckSsoRedirectUri: window.location.origin + '/silent-check-sso.html',
checkLoginIframe: false,
},
onReady: (keycloak) => {
console.log('Keycloak ready', keycloak.authenticated);
app.use(router);
app.mount('#app');
},
onInitError: (error) => {
console.error('Keycloak init error', error);
},
});5. Create Auth Composable
// composables/useAuth.js
import { inject, computed } from 'vue';
export function useAuth() {
const auth = inject('auth');
if (!auth) {
throw new Error('Auth plugin not installed');
}
return {
// State
isAuthenticated: computed(() => auth.state.isAuthenticated),
user: computed(() => auth.state.user),
loading: computed(() => auth.state.loading),
error: computed(() => auth.state.error),
// User info
userId: computed(() => auth.state.user?.sub),
username: computed(() => auth.state.user?.preferred_username),
email: computed(() => auth.state.user?.email),
fullName: computed(() => {
const user = auth.state.user;
if (!user) return '';
return `${user.given_name || ''} ${user.family_name || ''}`.trim();
}),
// Methods
login: auth.login,
logout: auth.logout,
register: auth.register,
hasRole: auth.hasRole,
hasAnyRole: auth.hasAnyRole,
hasAllRoles: auth.hasAllRoles,
getToken: auth.getToken,
};
}Component Implementation
Login Component
<!-- components/LoginButton.vue -->
<template>
<div>
<button v-if="!isAuthenticated" @click="handleLogin" class="btn-primary">
Login with Skycloak
</button>
<div v-else class="user-menu">
<span>Welcome, {{ fullName || username }}!</span>
<button @click="handleLogout" class="btn-secondary">Logout</button>
</div>
</div>
</template>
<script setup>
import { useAuth } from '@/composables/useAuth';
import { useRouter } from 'vue-router';
const router = useRouter();
const { isAuthenticated, username, fullName, login, logout } = useAuth();
const handleLogin = () => {
login({
redirectUri: window.location.origin + router.currentRoute.value.fullPath,
});
};
const handleLogout = () => {
logout({
redirectUri: window.location.origin,
});
};
</script>Protected Route Component
<!-- components/ProtectedRoute.vue -->
<template>
<div v-if="loading" class="loading">Loading authentication...</div>
<div v-else-if="!isAuthenticated" class="unauthorized">
<h2>Authentication Required</h2>
<p>Please log in to access this page.</p>
<button @click="login">Login</button>
</div>
<slot v-else />
</template>
<script setup>
import { useAuth } from '@/composables/useAuth';
import { onMounted } from 'vue';
const { isAuthenticated, loading, login } = useAuth();
onMounted(() => {
if (!loading.value && !isAuthenticated.value) {
// Optionally auto-redirect to login
// login()
}
});
</script>Role-Based Component
<!-- components/RoleGuard.vue -->
<template>
<div v-if="hasAccess">
<slot />
</div>
<div v-else-if="showFallback" class="access-denied">
<slot name="fallback">
<p>You don't have permission to view this content.</p>
</slot>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useAuth } from '@/composables/useAuth';
const props = defineProps({
roles: {
type: [String, Array],
required: true,
},
requireAll: {
type: Boolean,
default: false,
},
showFallback: {
type: Boolean,
default: true,
},
});
const { hasRole, hasAnyRole, hasAllRoles } = useAuth();
const hasAccess = computed(() => {
const roleArray = Array.isArray(props.roles) ? props.roles : [props.roles];
if (props.requireAll) {
return hasAllRoles(...roleArray);
} else {
return hasAnyRole(...roleArray);
}
});
</script>Router Guards
// router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import { useAuth } from '@/composables/useAuth';
const routes = [
{
path: '/',
name: 'Home',
component: () => import('../views/Home.vue'),
},
{
path: '/profile',
name: 'Profile',
component: () => import('../views/Profile.vue'),
meta: { requiresAuth: true },
},
{
path: '/admin',
name: 'Admin',
component: () => import('../views/Admin.vue'),
meta: {
requiresAuth: true,
roles: ['admin'],
},
},
{
path: '/editor',
name: 'Editor',
component: () => import('../views/Editor.vue'),
meta: {
requiresAuth: true,
roles: ['editor', 'admin'],
requireAll: false,
},
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
// Navigation guard
router.beforeEach((to, from, next) => {
const auth = useAuth();
// Wait for auth to be ready
if (auth.loading.value) {
const unwatch = auth.$watch('loading', (loading) => {
if (!loading) {
unwatch();
checkAuth();
}
});
return;
}
checkAuth();
function checkAuth() {
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth);
const requiredRoles = to.meta.roles;
if (requiresAuth && !auth.isAuthenticated.value) {
// Store intended route
sessionStorage.setItem('redirectPath', to.fullPath);
auth.login();
return;
}
if (requiredRoles && requiredRoles.length > 0) {
const requireAll = to.meta.requireAll || false;
const hasAccess = requireAll
? auth.hasAllRoles(...requiredRoles)
: auth.hasAnyRole(...requiredRoles);
if (!hasAccess) {
next({ name: 'AccessDenied' });
return;
}
}
next();
}
});
export default router;API Integration
Axios Interceptor
// services/api.js
import axios from 'axios';
import { useAuth } from '@/composables/useAuth';
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000,
});
// Request interceptor
api.interceptors.request.use(
async (config) => {
const auth = useAuth();
if (auth.isAuthenticated.value) {
try {
const token = await auth.getToken();
config.headers.Authorization = `Bearer ${token}`;
} catch (error) {
console.error('Failed to get token:', error);
}
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor
api.interceptors.response.use(
(response) => response,
async (error) => {
const auth = useAuth();
if (error.response?.status === 401) {
// Token might be expired
if (auth.isAuthenticated.value) {
// Try to refresh token
try {
await auth.getToken();
// Retry original request
return api.request(error.config);
} catch (refreshError) {
// Refresh failed, redirect to login
auth.logout();
}
}
}
return Promise.reject(error);
}
);
export default api;API Service Example
// services/userService.js
import api from './api';
export const userService = {
async getProfile() {
const response = await api.get('/users/profile');
return response.data;
},
async updateProfile(data) {
const response = await api.put('/users/profile', data);
return response.data;
},
async getUsers() {
const response = await api.get('/users');
return response.data;
},
async getUserById(id) {
const response = await api.get(`/users/${id}`);
return response.data;
},
};Pinia Store Integration
// stores/auth.js
import { defineStore } from 'pinia';
import { useAuth } from '@/composables/useAuth';
export const useAuthStore = defineStore('auth', {
state: () => ({
profile: null,
preferences: null,
permissions: [],
}),
getters: {
isProfileComplete: (state) => {
return state.profile?.firstName && state.profile?.lastName;
},
can: (state) => (permission) => {
return state.permissions.includes(permission);
},
},
actions: {
async loadProfile() {
const auth = useAuth();
if (!auth.isAuthenticated.value) {
throw new Error('User not authenticated');
}
try {
const response = await api.get('/users/profile');
this.profile = response.data;
} catch (error) {
console.error('Failed to load profile:', error);
throw error;
}
},
async updateProfile(data) {
try {
const response = await api.put('/users/profile', data);
this.profile = response.data;
return response.data;
} catch (error) {
console.error('Failed to update profile:', error);
throw error;
}
},
async loadPermissions() {
try {
const response = await api.get('/users/permissions');
this.permissions = response.data;
} catch (error) {
console.error('Failed to load permissions:', error);
throw error;
}
},
reset() {
this.profile = null;
this.preferences = null;
this.permissions = [];
},
},
});Advanced Features
Silent Authentication
Create public/silent-check-sso.html:
<!DOCTYPE html>
<html>
<head>
<title>Silent SSO Check</title>
</head>
<body>
<script>
parent.postMessage(location.href, location.origin);
</script>
</body>
</html>Custom Directives
// directives/auth.js
export const vAuth = {
mounted(el, binding) {
const { isAuthenticated } = useAuth();
const updateVisibility = () => {
el.style.display = isAuthenticated.value ? '' : 'none';
};
updateVisibility();
// Watch for changes
el._unwatchAuth = watch(isAuthenticated, updateVisibility);
},
unmounted(el) {
el._unwatchAuth?.();
},
};
export const vRole = {
mounted(el, binding) {
const { hasRole, hasAnyRole } = useAuth();
const roles = Array.isArray(binding.value) ? binding.value : [binding.value];
const requireAll = binding.modifiers.all;
const updateVisibility = () => {
const hasAccess = requireAll ? roles.every((role) => hasRole(role)) : hasAnyRole(...roles);
el.style.display = hasAccess ? '' : 'none';
};
updateVisibility();
// Watch for auth state changes
const { state } = useAuth();
el._unwatchRoles = watch(() => state.roles, updateVisibility);
},
unmounted(el) {
el._unwatchRoles?.();
},
};
// Register globally
app.directive('auth', vAuth);
app.directive('role', vRole);Usage:
<template>
<div>
<!-- Only show for authenticated users -->
<button v-auth @click="doSomething">Authenticated Action</button>
<!-- Only show for users with admin role -->
<div v-role="'admin'">Admin Panel</div>
<!-- Only show for users with either role -->
<div v-role="['editor', 'moderator']">Content Management</div>
<!-- Only show for users with all roles -->
<div v-role.all="['admin', 'super_user']">Super Admin Panel</div>
</div>
</template>Token Management Service
// services/tokenService.js
import { ref, computed } from 'vue';
import keycloak from '@/plugins/keycloak';
class TokenService {
constructor() {
this.tokenExpiry = ref(null);
this.refreshInterval = null;
}
get isTokenExpired() {
if (!this.tokenExpiry.value) return true;
return new Date() >= this.tokenExpiry.value;
}
get timeUntilExpiry() {
if (!this.tokenExpiry.value) return 0;
return Math.max(0, this.tokenExpiry.value - new Date());
}
startTokenRefresh() {
this.updateTokenExpiry();
// Clear existing interval
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
// Check token every 30 seconds
this.refreshInterval = setInterval(() => {
const timeLeft = this.timeUntilExpiry;
// Refresh if less than 5 minutes left
if (timeLeft < 5 * 60 * 1000) {
this.refreshToken();
}
}, 30000);
}
async refreshToken() {
try {
const refreshed = await keycloak.updateToken(30);
if (refreshed) {
this.updateTokenExpiry();
console.log('Token refreshed successfully');
}
return refreshed;
} catch (error) {
console.error('Failed to refresh token:', error);
throw error;
}
}
updateTokenExpiry() {
if (keycloak.tokenParsed?.exp) {
this.tokenExpiry.value = new Date(keycloak.tokenParsed.exp * 1000);
}
}
stopTokenRefresh() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
this.refreshInterval = null;
}
}
async getValidToken() {
if (this.isTokenExpired) {
await this.refreshToken();
}
return keycloak.token;
}
}
export const tokenService = new TokenService();Testing
Unit Tests
// tests/unit/composables/useAuth.spec.js
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAuth } from '@/composables/useAuth';
// Mock the inject function
vi.mock('vue', async () => {
const actual = await vi.importActual('vue');
return {
...actual,
inject: vi.fn(),
};
});
describe('useAuth', () => {
let mockAuth;
beforeEach(() => {
mockAuth = {
state: {
isAuthenticated: true,
user: {
sub: 'user-123',
preferred_username: 'testuser',
email: '[email protected]',
given_name: 'Test',
family_name: 'User',
},
loading: false,
error: null,
},
login: vi.fn(),
logout: vi.fn(),
hasRole: vi.fn(),
hasAnyRole: vi.fn(),
hasAllRoles: vi.fn(),
getToken: vi.fn(),
};
vi.mocked(inject).mockReturnValue(mockAuth);
});
it('returns computed properties correctly', () => {
const auth = useAuth();
expect(auth.isAuthenticated.value).toBe(true);
expect(auth.username.value).toBe('testuser');
expect(auth.email.value).toBe('[email protected]');
expect(auth.fullName.value).toBe('Test User');
});
it('calls login method correctly', () => {
const auth = useAuth();
const options = { redirectUri: 'http://localhost:3000' };
auth.login(options);
expect(mockAuth.login).toHaveBeenCalledWith(options);
});
it('checks roles correctly', () => {
const auth = useAuth();
mockAuth.hasRole.mockReturnValue(true);
const result = auth.hasRole('admin');
expect(mockAuth.hasRole).toHaveBeenCalledWith('admin');
expect(result).toBe(true);
});
});Component Tests
// tests/unit/components/RoleGuard.spec.js
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import RoleGuard from '@/components/RoleGuard.vue';
import { useAuth } from '@/composables/useAuth';
vi.mock('@/composables/useAuth');
describe('RoleGuard', () => {
it('shows content when user has required role', () => {
vi.mocked(useAuth).mockReturnValue({
hasAnyRole: vi.fn().mockReturnValue(true),
hasAllRoles: vi.fn().mockReturnValue(true),
});
const wrapper = mount(RoleGuard, {
props: {
roles: 'admin',
},
slots: {
default: '<div>Protected Content</div>',
},
});
expect(wrapper.text()).toContain('Protected Content');
});
it('shows fallback when user lacks required role', () => {
vi.mocked(useAuth).mockReturnValue({
hasAnyRole: vi.fn().mockReturnValue(false),
hasAllRoles: vi.fn().mockReturnValue(false),
});
const wrapper = mount(RoleGuard, {
props: {
roles: 'admin',
showFallback: true,
},
slots: {
fallback: '<div>Access Denied</div>',
},
});
expect(wrapper.text()).toContain('Access Denied');
});
it('checks all roles when requireAll is true', () => {
const hasAllRoles = vi.fn().mockReturnValue(true);
vi.mocked(useAuth).mockReturnValue({
hasAnyRole: vi.fn(),
hasAllRoles,
});
mount(RoleGuard, {
props: {
roles: ['admin', 'editor'],
requireAll: true,
},
});
expect(hasAllRoles).toHaveBeenCalledWith('admin', 'editor');
});
});E2E Tests
// tests/e2e/auth.spec.js
import { test, expect } from '@playwright/test';
test.describe('Authentication Flow', () => {
test('redirects to Keycloak login', async ({ page }) => {
await page.goto('/profile');
// Should redirect to Keycloak
await expect(page).toHaveURL(/.*your-cluster\.skycloak\.com.*/);
await expect(page).toHaveURL(/.*protocol\/openid-connect\/auth.*/);
});
test('shows user info after login', async ({ page }) => {
// Mock authenticated state
await page.addInitScript(() => {
window.localStorage.setItem('kc-token', 'mock-token');
});
await page.goto('/');
// Check for authenticated UI
await expect(page.locator('text=Welcome')).toBeVisible();
await expect(page.locator('button:has-text("Logout")')).toBeVisible();
});
test('protects admin routes', async ({ page }) => {
// Mock authenticated state without admin role
await page.addInitScript(() => {
window.localStorage.setItem('kc-token', 'mock-token');
window.mockRoles = ['user'];
});
await page.goto('/admin');
// Should show access denied
await expect(page.locator('text=Access Denied')).toBeVisible();
});
});Production Considerations
Environment Configuration
// .env.production
VITE_KEYCLOAK_URL=https://auth.yourcompany.com
VITE_KEYCLOAK_REALM=production
VITE_KEYCLOAK_CLIENT_ID=vue-app-prod
VITE_API_BASE_URL=https://api.yourcompany.com
Build Configuration
// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
build: {
rollupOptions: {
output: {
manualChunks: {
keycloak: ['keycloak-js'],
vendor: ['vue', 'vue-router', 'pinia'],
},
},
},
},
define: {
// Feature flags
__VUE_PROD_DEVTOOLS__: false,
__ENABLE_KEYCLOAK_DEBUG__: false,
},
});Security Headers
# nginx.conf
server {
# Security headers
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https://*.skycloak.io https://api.yourcompany.com; frame-src 'self' https://*.skycloak.io;" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# SPA routing
location / {
try_files $uri $uri/ /index.html;
}
}Performance Optimization
// Lazy load auth-heavy components
const AdminPanel = () =>
import(
/* webpackChunkName: "admin" */
'@/views/AdminPanel.vue'
);
// Prefetch auth endpoints
const prefetchAuthEndpoints = () => {
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = `${import.meta.env.VITE_KEYCLOAK_URL}/realms/${
import.meta.env.VITE_KEYCLOAK_REALM
}/.well-known/openid-configuration`;
document.head.appendChild(link);
};
// Call on app initialization
prefetchAuthEndpoints();Troubleshooting
Common Issues
-
CORS Errors
- Add your Vue app URL to Keycloak client’s Web Origins
- Ensure API allows credentials from your domain
- Check for proper CORS headers in responses
-
Silent Check SSO Failing
- Verify
silent-check-sso.htmlis accessible - Check third-party cookie settings in browser
- Ensure redirect URI is registered in Keycloak
- Verify
-
Token Refresh Loop
- Check token expiration settings in Keycloak
- Verify clock synchronization between client and server
- Implement exponential backoff for refresh attempts
Debug Mode
// plugins/keycloak-debug.js
export function enableKeycloakDebug() {
if (import.meta.env.DEV) {
window.addEventListener('keycloak-token-expired', () => {
console.log('Token expired event');
});
window.addEventListener('keycloak-auth-success', () => {
console.log('Auth success event');
});
window.addEventListener('keycloak-auth-error', (e) => {
console.error('Auth error event', e);
});
// Log all Keycloak events
const originalInit = keycloak.init;
keycloak.init = function (...args) {
console.log('Keycloak init called with:', args);
return originalInit.apply(this, args);
};
}
}Performance Monitoring
// plugins/auth-performance.js
export function monitorAuthPerformance() {
const metrics = {
initStart: 0,
initEnd: 0,
loginStart: 0,
loginEnd: 0,
tokenRefreshCount: 0,
};
// Monitor init time
performance.mark('keycloak-init-start');
keycloak.onReady = () => {
performance.mark('keycloak-init-end');
performance.measure('keycloak-init', 'keycloak-init-start', 'keycloak-init-end');
const measure = performance.getEntriesByName('keycloak-init')[0];
console.log(`Keycloak initialized in ${measure.duration}ms`);
};
// Monitor token refresh
const originalUpdateToken = keycloak.updateToken;
keycloak.updateToken = function (...args) {
metrics.tokenRefreshCount++;
const start = performance.now();
return originalUpdateToken.apply(this, args).then((result) => {
const duration = performance.now() - start;
console.log(`Token refresh #${metrics.tokenRefreshCount} took ${duration}ms`);
return result;
});
};
return metrics;
}