Vue.js Integration Example
SSO Version: 5.24.0
Overview
This guide demonstrates OAuth 2.0 Authorization Code Flow integration in a Vue 3 application using Composition API and Pinia for state management.
⚠️ Security Note: Never expose
client_secret in your Vue app. All token exchange operations must happen on your backend server.1. Project Setup
Install Pinia for state management:
npm install pinia npm install vue-router # If not already installed
2. Environment Configuration
Configure your environment variables:
# .env (Frontend - safe to expose CLIENT_ID only) VITE_SSO_CLIENT_ID=your_client_id_here VITE_SSO_BASE_URL=https://sso.doneisbetter.com # .env (Backend - NEVER commit this file) SSO_CLIENT_SECRET=your_client_secret_here SSO_REDIRECT_URI=https://yourapp.com/api/auth/callback
3. Pinia Auth Store
Create a Pinia store to manage authentication state:
// src/stores/auth.js
import { defineStore } from 'pinia';
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null,
loading: true,
permissionStatus: null // 'approved', 'pending', 'revoked'
}),
getters: {
isAuthenticated: (state) => !!state.user,
isApproved: (state) => state.permissionStatus === 'approved',
isPending: (state) => state.permissionStatus === 'pending',
isRevoked: (state) => state.permissionStatus === 'revoked'
},
actions: {
// WHY: Check if user has a valid session with your backend
async checkSession() {
try {
const response = await fetch('/api/auth/session', {
credentials: 'include' // Include cookies
});
if (response.ok) {
const data = await response.json();
this.user = data.user;
this.permissionStatus = data.permissionStatus;
}
} catch (error) {
console.error('Session check failed:', error);
} finally {
this.loading = false;
}
},
// WHY: Redirect user to SSO authorization page
login() {
// Generate random state for CSRF protection
const state = Math.random().toString(36).substring(7);
sessionStorage.setItem('oauth_state', state);
const authUrl = new URL(import.meta.env.VITE_SSO_BASE_URL + '/api/oauth/authorize');
authUrl.searchParams.append('client_id', import.meta.env.VITE_SSO_CLIENT_ID);
authUrl.searchParams.append('redirect_uri', window.location.origin + '/api/auth/callback');
authUrl.searchParams.append('response_type', 'code');
authUrl.searchParams.append('scope', 'openid profile email');
authUrl.searchParams.append('state', state);
window.location.href = authUrl.toString();
},
// WHY: Clear session and redirect to SSO logout
async logout() {
try {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include'
});
this.user = null;
this.permissionStatus = null;
// Optional: Also logout from SSO
window.location.href = import.meta.env.VITE_SSO_BASE_URL + '/api/public/logout';
} catch (error) {
console.error('Logout failed:', error);
}
}
}
});4. Backend OAuth Callback (Express/Node.js)
Handle the OAuth callback and exchange code for tokens:
// server/routes/auth.js (Express example)
import express from 'express';
import jwt from 'jsonwebtoken';
const router = express.Router();
// OAuth Callback Endpoint
router.get('/api/auth/callback', async (req, res) => {
const { code, state } = req.query;
// WHY: Validate state parameter to prevent CSRF attacks
const expectedState = req.cookies.oauth_state;
if (state !== expectedState) {
return res.status(400).json({ error: 'Invalid state parameter' });
}
try {
// WHY: Exchange authorization code for tokens (server-side only)
const tokenResponse = await fetch(
`${process.env.SSO_BASE_URL}/api/oauth/token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
code,
redirect_uri: process.env.SSO_REDIRECT_URI,
client_id: process.env.SSO_CLIENT_ID,
client_secret: process.env.SSO_CLIENT_SECRET
})
}
);
if (!tokenResponse.ok) {
const error = await tokenResponse.json();
return res.status(tokenResponse.status).json(error);
}
const tokens = await tokenResponse.json();
const { access_token, id_token, refresh_token } = tokens;
// WHY: Decode ID token to get user info and app permissions
const decoded = jwt.decode(id_token);
const { sub: userId, email, name, role, permissionStatus } = decoded;
// WHY: Store tokens securely in HTTP-only cookies
res.cookie('access_token', access_token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 3600000 // 1 hour
});
res.cookie('refresh_token', refresh_token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 2592000000 // 30 days
});
res.cookie('id_token', id_token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 3600000
});
// WHY: Check app permission status and redirect accordingly
if (permissionStatus === 'pending') {
return res.redirect('/access-pending');
} else if (permissionStatus === 'revoked') {
return res.redirect('/access-denied');
}
// Success: Redirect to app
res.redirect('/dashboard');
} catch (error) {
console.error('OAuth callback error:', error);
res.status(500).json({ error: 'Authentication failed' });
}
});
// Session Validation Endpoint
router.get('/api/auth/session', (req, res) => {
const { id_token, access_token } = req.cookies;
if (!id_token || !access_token) {
return res.status(401).json({ error: 'Not authenticated' });
}
try {
const decoded = jwt.decode(id_token);
const { sub: userId, email, name, role, permissionStatus } = decoded;
if (decoded.exp * 1000 < Date.now()) {
return res.status(401).json({ error: 'Token expired' });
}
res.json({
user: { userId, email, name, role },
permissionStatus
});
} catch (error) {
console.error('Session validation error:', error);
res.status(500).json({ error: 'Session validation failed' });
}
});
// Logout Endpoint
router.post('/api/auth/logout', (req, res) => {
res.clearCookie('access_token');
res.clearCookie('refresh_token');
res.clearCookie('id_token');
res.json({ success: true });
});
export default router;5. Auth Composable (Optional)
Create a reusable composable for auth logic:
// src/composables/useAuth.js
import { computed } from 'vue';
import { useAuthStore } from '@/stores/auth';
import { useRouter } from 'vue-router';
export function useAuth() {
const authStore = useAuthStore();
const router = useRouter();
const user = computed(() => authStore.user);
const loading = computed(() => authStore.loading);
const isAuthenticated = computed(() => authStore.isAuthenticated);
const isApproved = computed(() => authStore.isApproved);
const permissionStatus = computed(() => authStore.permissionStatus);
const requireAuth = () => {
if (!isAuthenticated.value) {
router.push('/login');
return false;
}
return true;
};
const requireApproval = () => {
if (!requireAuth()) return false;
if (!isApproved.value) {
if (authStore.isPending) {
router.push('/access-pending');
} else if (authStore.isRevoked) {
router.push('/access-denied');
}
return false;
}
return true;
};
return {
user,
loading,
isAuthenticated,
isApproved,
permissionStatus,
login: () => authStore.login(),
logout: () => authStore.logout(),
requireAuth,
requireApproval
};
}6. Router Guards
Protect routes that require authentication:
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
const routes = [
{
path: '/',
name: 'Home',
component: () => import('@/views/Home.vue')
},
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue')
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { requiresAuth: true, requiresApproval: true }
},
{
path: '/access-pending',
name: 'AccessPending',
component: () => import('@/views/AccessPending.vue'),
meta: { requiresAuth: true }
},
{
path: '/access-denied',
name: 'AccessDenied',
component: () => import('@/views/AccessDenied.vue')
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
// WHY: Global navigation guard to check authentication and permissions
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore();
// WHY: Wait for session check to complete
if (authStore.loading) {
await authStore.checkSession();
}
const requiresAuth = to.matched.some(record => record.meta.requiresAuth);
const requiresApproval = to.matched.some(record => record.meta.requiresApproval);
if (requiresAuth && !authStore.isAuthenticated) {
next('/login');
} else if (requiresApproval && !authStore.isApproved) {
if (authStore.isPending) {
next('/access-pending');
} else if (authStore.isRevoked) {
next('/access-denied');
} else {
next();
}
} else {
next();
}
});
export default router;7. Component Examples
<!-- src/views/Login.vue -->
<template>
<div class="login-page">
<h1>Sign In</h1>
<button @click="handleLogin" :disabled="loading">
{{ loading ? 'Loading...' : 'Sign in with SSO' }}
</button>
</div>
</template>
<script setup>
import { useAuth } from '@/composables/useAuth';
const { login, loading } = useAuth();
const handleLogin = () => {
login();
};
</script>
<!-- src/views/Dashboard.vue -->
<template>
<div class="dashboard">
<h1>Welcome, {{ user?.name }}</h1>
<p>Email: {{ user?.email }}</p>
<p>Role: {{ user?.role }}</p>
<button @click="handleLogout">Sign Out</button>
</div>
</template>
<script setup>
import { useAuth } from '@/composables/useAuth';
import { onMounted } from 'vue';
const { user, logout, requireApproval } = useAuth();
onMounted(() => {
// WHY: Ensure user is approved before showing dashboard
requireApproval();
});
const handleLogout = () => {
logout();
};
</script>
<!-- src/views/AccessPending.vue -->
<template>
<div class="access-pending">
<h1>Access Pending</h1>
<p>
Your access to this application is pending approval.
An administrator will review your request shortly.
</p>
<p>You will receive an email notification once approved.</p>
<button @click="logout">Sign Out</button>
</div>
</template>
<script setup>
import { useAuth } from '@/composables/useAuth';
const { logout } = useAuth();
</script>8. App Setup
Initialize Pinia and check session on app mount:
// src/main.js
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import router from './router';
import App from './App.vue';
import { useAuthStore } from './stores/auth';
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.use(router);
// WHY: Check session on app initialization
const authStore = useAuthStore();
authStore.checkSession();
app.mount('#app');Summary
- ✅ OAuth 2.0 Authorization Code Flow with Vue 3 Composition API
- ✅ Pinia store for centralized auth state management
- ✅ Router guards for protected routes
- ✅ Reusable auth composable
- ✅ App permission status handling (pending/approved/revoked)
- ✅ Secure token storage with HTTP-only cookies
🔗 Next Steps:
- Review Authentication Flow for detailed OAuth 2.0 explanation
- Check App Permissions to understand permission lifecycle
- See API Reference for complete endpoint documentation