Document missing OAuth2 features and security improvements: - State parameter for CSRF protection - PKCE implementation - Refresh tokens and expiration handling - SSO logout with Authentik - Redirect after login - RBAC middleware - Session timeout warnings - Auto-refresh mechanisms Organized by priority with code examples and references.
22 KiB
TODO - OAuth2/OIDC Improvements
Este documento lista las funcionalidades OAuth2/OIDC que faltan en el template actual y que podrían implementarse en futuras versiones.
🔴 Críticas (Seguridad)
1. State Parameter (CSRF Protection)
Estado: ❌ No implementado Prioridad: ALTA Riesgo: Vulnerable a ataques CSRF
Descripción:
El parámetro state es un token aleatorio que se envía en la URL de autorización y debe ser verificado en el callback para prevenir ataques CSRF.
Implementación sugerida:
// En authentik.get.ts - antes del redirect
const state = generateRandomString(32)
await setUserSession(event, { oauthState: state })
const authorizationUrl = withQuery(
`${config.serverUrl}/application/o/authorize/`,
{
client_id: config.clientId,
redirect_uri: config.redirectURL,
response_type: 'code',
scope: config.scope.join(' '),
state: state // Agregar state
}
)
// En callback - verificar state
if (query.state !== session.oauthState) {
throw new Error('Invalid state parameter')
}
Referencias:
2. PKCE (Proof Key for Code Exchange)
Estado: ❌ No implementado Prioridad: MEDIA Beneficio: Protección adicional contra intercepción del authorization code
Descripción: PKCE es una extensión de OAuth 2.0 que protege contra ataques de intercepción del código de autorización. Es especialmente recomendado para aplicaciones públicas.
Implementación sugerida:
// Generar code_verifier y code_challenge
import { createHash, randomBytes } from 'crypto'
const codeVerifier = randomBytes(32).toString('base64url')
const codeChallenge = createHash('sha256')
.update(codeVerifier)
.digest('base64url')
// Guardar code_verifier en sesión
await setUserSession(event, { codeVerifier })
// Agregar a URL de autorización
const authorizationUrl = withQuery(url, {
// ... otros params
code_challenge: codeChallenge,
code_challenge_method: 'S256'
})
// En token exchange, agregar code_verifier
body: new URLSearchParams({
// ... otros params
code_verifier: session.codeVerifier
})
Referencias:
🟡 Importantes (Funcionalidad)
3. Refresh Tokens
Estado: ❌ No implementado Prioridad: ALTA Problema: Usuario debe re-autenticarse cuando expira el access token
Descripción: Actualmente no guardamos ni usamos refresh tokens, lo que significa que cuando el access token expira, el usuario debe volver a autenticarse completamente.
Implementación sugerida:
// Guardar tokens en sesión
await setUserSession(event, {
user: { ... },
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: Date.now() + (tokens.expires_in * 1000),
loggedInAt: Date.now()
})
// Crear endpoint para refresh
// server/api/auth/refresh.post.ts
export default defineEventHandler(async (event) => {
const session = await getUserSession(event)
const tokenResponse = await $fetch(tokenUrl, {
method: 'POST',
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: session.refreshToken,
client_id: config.clientId,
client_secret: config.clientSecret,
})
})
// Actualizar sesión con nuevos tokens
await setUserSession(event, {
...session,
accessToken: tokenResponse.access_token,
refreshToken: tokenResponse.refresh_token || session.refreshToken,
expiresAt: Date.now() + (tokenResponse.expires_in * 1000)
})
return { success: true }
})
Consideraciones:
- Implementar refresh automático en cliente antes de que expire
- Manejar caso cuando refresh token también expira
- Considerar refresh token rotation para mayor seguridad
4. Token Storage & Expiration Handling
Estado: ⚠️ Parcialmente implementado Prioridad: ALTA Problema: Solo guardamos info del usuario, no los tokens ni su expiración
Descripción: Actualmente solo extraemos la información del usuario y descartamos los tokens. Necesitamos guardarlos para poder renovarlos y detectar cuándo expiran.
Implementación sugerida:
// Extender UserSession interface
declare module '#auth-utils' {
interface UserSession {
user: User
accessToken: string
refreshToken?: string
idToken?: string
expiresAt: number
loggedInAt: number
}
}
// Guardar todos los tokens
await setUserSession(event, {
user: { ... },
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token,
expiresAt: Date.now() + (tokens.expires_in * 1000),
loggedInAt: Date.now()
})
// Middleware para verificar expiración
export default defineNuxtRouteMiddleware(async (to, from) => {
const { loggedIn } = useUserSession()
const session = await $fetch('/api/auth/session')
if (loggedIn.value && session.expiresAt < Date.now()) {
// Token expirado, intentar refresh
try {
await $fetch('/api/auth/refresh', { method: 'POST' })
} catch {
// Refresh falló, redirigir a login
return navigateTo('/login')
}
}
})
5. Single Sign-Out (SSO Logout)
Estado: ❌ No implementado Prioridad: MEDIA Problema: Solo cerramos sesión local, no en Authentik
Descripción: Actualmente al cerrar sesión solo limpiamos la sesión local, pero el usuario sigue autenticado en Authentik. Al volver a iniciar sesión, entra automáticamente sin pedir credenciales.
Implementación sugerida:
// server/api/auth/logout.get.ts
export default defineEventHandler(async (event) => {
const session = await getUserSession(event)
const runtimeConfig = useRuntimeConfig(event)
// Limpiar sesión local
await clearUserSession(event)
// Redirigir a logout de Authentik
const logoutUrl = withQuery(
`${runtimeConfig.oauth.authentik.serverUrl}/application/o/end-session/`,
{
id_token_hint: session.idToken,
post_logout_redirect_uri: runtimeConfig.public.appUrl
}
)
return sendRedirect(event, logoutUrl)
})
Consideraciones:
- Requiere guardar el
id_tokenen la sesión - Authentik debe tener configurada la URL de post-logout redirect
- Opcionalmente agregar parámetro
statepara validar el retorno
6. Redirect After Login
Estado: ⚠️ Parcialmente implementado Prioridad: MEDIA Problema: No recordamos a dónde quería ir el usuario antes del login
Descripción:
Cuando el middleware redirige al login, incluye el parámetro redirect en la URL, pero el flujo OAuth no lo preserva. El usuario siempre termina en / después de autenticarse.
Implementación sugerida:
// En login.vue - capturar redirect del query
const route = useRoute()
const redirectUrl = route.query.redirect || '/'
const login = () => {
// Guardar en sessionStorage
if (process.client) {
sessionStorage.setItem('auth_redirect', redirectUrl)
}
navigateTo('/api/auth/authentik')
}
// En server/api/auth/authentik.get.ts - después de crear sesión
export default defineEventHandler(async (event) => {
// ... código existente ...
await setUserSession(event, { ... })
// Enviar HTML con script para leer sessionStorage
return `
<!DOCTYPE html>
<html>
<head><title>Redirecting...</title></head>
<body>
<script>
const redirect = sessionStorage.getItem('auth_redirect') || '/';
sessionStorage.removeItem('auth_redirect');
window.location.href = redirect;
</script>
</body>
</html>
`
})
Alternativa usando cookies:
// Guardar en cookie en lugar de sessionStorage
setCookie(event, 'auth_redirect', redirectUrl, {
httpOnly: true,
secure: true,
maxAge: 300 // 5 minutos
})
// Leer en callback
const redirectUrl = getCookie(event, 'auth_redirect') || '/'
deleteCookie(event, 'auth_redirect')
return sendRedirect(event, redirectUrl)
🟢 Nice to Have (UX)
7. Error Messages Específicos
Estado: ⚠️ Implementación básica
Prioridad: BAJA
Problema: Solo tenemos /?error=auth_failed genérico
Descripción: Actualmente todos los errores muestran el mismo mensaje. Sería mejor tener mensajes específicos para cada tipo de error.
Implementación sugerida:
// En server/api/auth/authentik.get.ts
try {
// ... token exchange ...
} catch (error: any) {
console.error('Authentik OAuth error:', error)
let errorCode = 'auth_failed'
if (error.statusCode === 401) {
errorCode = 'invalid_credentials'
} else if (error.statusCode === 403) {
errorCode = 'access_denied'
} else if (error.code === 'ETIMEDOUT') {
errorCode = 'server_timeout'
} else if (error.code === 'ECONNREFUSED') {
errorCode = 'server_unavailable'
}
return sendRedirect(event, `/?error=${errorCode}`)
}
// En login.vue - mostrar mensajes apropiados
const errorMessages = {
auth_failed: 'Error de autenticación. Por favor intenta nuevamente.',
invalid_credentials: 'Credenciales inválidas.',
access_denied: 'Acceso denegado. No tienes permisos para acceder.',
server_timeout: 'Timeout del servidor. Por favor intenta más tarde.',
server_unavailable: 'Servidor no disponible. Por favor intenta más tarde.'
}
const route = useRoute()
const errorMessage = computed(() => {
const error = route.query.error as string
return error ? errorMessages[error] || errorMessages.auth_failed : null
})
8. Loading States
Estado: ❌ No implementado Prioridad: BAJA Problema: No hay feedback visual durante el callback OAuth
Descripción:
Durante el callback OAuth (cuando hay ?code=... en la URL), no hay indicación visual de que se está procesando. El usuario ve la página anterior brevemente.
Implementación sugerida:
// Crear app/pages/auth-callback.vue
<template>
<div class="min-h-screen flex items-center justify-center">
<div class="text-center">
<div class="spinner"></div>
<p class="mt-4">Completando inicio de sesión...</p>
</div>
</div>
</template>
// En server/api/auth/authentik.get.ts
// Detectar callback y redirigir a página de loading primero
if (query.code) {
// Si es primera vez con el code, redirigir a loading page
if (!query.processed) {
return sendRedirect(
event,
`/auth-callback?code=${query.code}&state=${query.state}&processed=true`
)
}
// Procesar el code
try {
// ... código existente ...
}
}
Alternativa más simple:
// En login.vue, detectar callback en mounted
onMounted(() => {
const route = useRoute()
if (route.query.code) {
// Mostrar loading
isLoading.value = true
}
})
9. Role-Based Access Control (RBAC)
Estado: ⚠️ Datos disponibles pero no implementado Prioridad: MEDIA Problema: No hay protección por roles/grupos
Descripción: Actualmente guardamos los grupos de Authentik pero no los usamos para proteger rutas o funcionalidades.
Implementación sugerida:
// app/middleware/admin.ts
export default defineNuxtRouteMiddleware((to, from) => {
const { user } = useUserSession()
if (!user.value?.groups?.includes('authentik Admins')) {
return navigateTo('/unauthorized')
}
})
// app/middleware/requires-group.ts
export default defineNuxtRouteMiddleware((to, from) => {
const { user } = useUserSession()
const requiredGroup = to.meta.requiredGroup as string
if (requiredGroup && !user.value?.groups?.includes(requiredGroup)) {
return navigateTo('/unauthorized')
}
})
// Usar en páginas
<script setup>
definePageMeta({
middleware: ['auth', 'admin']
})
</script>
// O con grupo específico
definePageMeta({
middleware: ['auth', 'requires-group'],
requiredGroup: 'editors'
})
// Composable para verificar permisos
export const usePermissions = () => {
const { user } = useUserSession()
const hasGroup = (group: string) => {
return user.value?.groups?.includes(group) ?? false
}
const hasAnyGroup = (groups: string[]) => {
return groups.some(group => hasGroup(group))
}
const hasAllGroups = (groups: string[]) => {
return groups.every(group => hasGroup(group))
}
const isAdmin = computed(() => hasGroup('authentik Admins'))
return {
hasGroup,
hasAnyGroup,
hasAllGroups,
isAdmin
}
}
// Usar en componentes
const { isAdmin, hasGroup } = usePermissions()
<UButton v-if="isAdmin" @click="deleteUser">
Eliminar Usuario
</UButton>
En el backend:
// server/api/admin/users.delete.ts
export default defineEventHandler(async (event) => {
const session = await requireUserSession(event)
// Verificar que el usuario es admin
if (!(session.user as any).groups?.includes('authentik Admins')) {
throw createError({
statusCode: 403,
message: 'Forbidden: Admin access required'
})
}
// ... lógica para eliminar usuario ...
})
// O crear utility
export const requireAdmin = async (event: H3Event) => {
const session = await requireUserSession(event)
if (!(session.user as any).groups?.includes('authentik Admins')) {
throw createError({
statusCode: 403,
message: 'Forbidden: Admin access required'
})
}
return session
}
// Usar
export default defineEventHandler(async (event) => {
const session = await requireAdmin(event)
// ... rest of handler ...
})
10. Session Timeout Warning
Estado: ❌ No implementado Prioridad: BAJA Problema: No avisamos cuando va a expirar la sesión
Descripción: Sería bueno avisar al usuario antes de que expire su sesión para que pueda renovarla o guardar su trabajo.
Implementación sugerida:
// composables/useSessionTimeout.ts
export const useSessionTimeout = () => {
const { loggedIn } = useUserSession()
const showWarning = ref(false)
const timeRemaining = ref(0)
let warningTimeout: NodeJS.Timeout
let checkInterval: NodeJS.Timeout
const checkExpiration = async () => {
if (!loggedIn.value) return
try {
const session = await $fetch('/api/auth/session')
const remaining = session.expiresAt - Date.now()
timeRemaining.value = Math.floor(remaining / 1000)
// Mostrar warning 5 minutos antes de expirar
if (remaining < 5 * 60 * 1000 && remaining > 0) {
showWarning.value = true
}
// Si ya expiró, redirigir a login
if (remaining <= 0) {
await navigateTo('/login?error=session_expired')
}
} catch (error) {
console.error('Error checking session:', error)
}
}
const renewSession = async () => {
try {
await $fetch('/api/auth/refresh', { method: 'POST' })
showWarning.value = false
useToast().add({
title: 'Sesión renovada',
color: 'success'
})
} catch (error) {
useToast().add({
title: 'Error al renovar sesión',
color: 'error'
})
}
}
onMounted(() => {
if (loggedIn.value) {
checkExpiration()
// Verificar cada minuto
checkInterval = setInterval(checkExpiration, 60 * 1000)
}
})
onUnmounted(() => {
if (checkInterval) clearInterval(checkInterval)
if (warningTimeout) clearTimeout(warningTimeout)
})
return {
showWarning,
timeRemaining,
renewSession
}
}
// Usar en app.vue o layout
<script setup>
const { showWarning, timeRemaining, renewSession } = useSessionTimeout()
</script>
<template>
<div>
<!-- Modal de advertencia -->
<UModal v-model="showWarning">
<UCard>
<template #header>
<h3>Tu sesión está por expirar</h3>
</template>
<p>
Tu sesión expirará en {{ Math.floor(timeRemaining / 60) }} minutos.
¿Deseas extender tu sesión?
</p>
<template #footer>
<div class="flex gap-2 justify-end">
<UButton color="gray" @click="showWarning = false">
Cerrar
</UButton>
<UButton @click="renewSession">
Extender sesión
</UButton>
</div>
</template>
</UCard>
</UModal>
<slot />
</div>
</template>
11. Token Refresh Automático
Estado: ❌ No implementado Prioridad: MEDIA Problema: No renovamos tokens automáticamente en background
Descripción: Relacionado con refresh tokens (#3) y session timeout (#10), debería haber un mecanismo automático que renueve los tokens antes de que expiren, sin intervención del usuario.
Implementación sugerida:
// composables/useAutoRefresh.ts
export const useAutoRefresh = () => {
const { loggedIn } = useUserSession()
let refreshTimeout: NodeJS.Timeout
const scheduleRefresh = async () => {
if (!loggedIn.value) return
try {
const session = await $fetch('/api/auth/session')
const timeUntilExpiry = session.expiresAt - Date.now()
// Renovar 5 minutos antes de que expire
const refreshTime = timeUntilExpiry - (5 * 60 * 1000)
if (refreshTime > 0) {
refreshTimeout = setTimeout(async () => {
try {
await $fetch('/api/auth/refresh', { method: 'POST' })
console.log('Token refreshed automatically')
// Programar próximo refresh
scheduleRefresh()
} catch (error) {
console.error('Auto-refresh failed:', error)
// Opcionalmente redirigir a login
}
}, refreshTime)
}
} catch (error) {
console.error('Error scheduling refresh:', error)
}
}
onMounted(() => {
if (loggedIn.value) {
scheduleRefresh()
}
})
onUnmounted(() => {
if (refreshTimeout) clearTimeout(refreshTimeout)
})
watch(loggedIn, (newValue) => {
if (newValue) {
scheduleRefresh()
} else {
if (refreshTimeout) clearTimeout(refreshTimeout)
}
})
}
// Usar en app.vue
<script setup>
useAutoRefresh()
</script>
Alternativa con Web Worker:
// public/refresh-worker.js
let refreshTimer = null
self.addEventListener('message', (event) => {
const { type, expiresAt } = event.data
if (type === 'schedule') {
if (refreshTimer) clearTimeout(refreshTimer)
const timeUntilExpiry = expiresAt - Date.now()
const refreshTime = timeUntilExpiry - (5 * 60 * 1000)
if (refreshTime > 0) {
refreshTimer = setTimeout(() => {
self.postMessage({ type: 'refresh-needed' })
}, refreshTime)
}
} else if (type === 'cancel') {
if (refreshTimer) clearTimeout(refreshTimer)
}
})
// Usar desde composable
const worker = new Worker('/refresh-worker.js')
worker.onmessage = async (event) => {
if (event.data.type === 'refresh-needed') {
await $fetch('/api/auth/refresh', { method: 'POST' })
// Re-schedule
const session = await $fetch('/api/auth/session')
worker.postMessage({ type: 'schedule', expiresAt: session.expiresAt })
}
}
📊 Prioridades Recomendadas
Para Template de Producción (Crítico):
- ✅ State Parameter - Seguridad crítica (CSRF protection)
- ✅ Refresh Tokens - Mejor UX, evita re-autenticación constante
- ✅ Token Storage & Expiration - Base para refresh tokens
- ✅ SSO Logout - Logout completo en Authentik
Importante (Alta prioridad):
- ✅ Redirect After Login - UX básica esperada
- ✅ Error Messages - Debugging y mejor UX
- ✅ RBAC - Control de acceso por grupos
Nice to Have (Media/Baja prioridad):
- ⭕ PKCE - Seguridad adicional (cada vez más recomendado)
- ⭕ Loading States - Mejor UX durante OAuth flow
- ⭕ Session Timeout Warning - UX avanzada
- ⭕ Auto Refresh - UX premium, sesión sin interrupciones
🎯 Roadmap Sugerido
Fase 1 - Seguridad Básica (1-2 días)
- Implementar State Parameter
- Mejorar manejo de errores con mensajes específicos
Fase 2 - Token Management (2-3 días)
- Guardar todos los tokens en sesión
- Implementar refresh token endpoint
- Implementar detección de expiración
Fase 3 - UX Mejorada (2-3 días)
- Redirect after login
- SSO Logout con Authentik
- Loading states durante OAuth
Fase 4 - RBAC (1-2 días)
- Middleware para roles
- Composable de permisos
- Ejemplos de protección por grupos
Fase 5 - Advanced (3-4 días)
- PKCE implementation
- Auto-refresh automático
- Session timeout warning
- Token refresh automático en background
📚 Referencias
- OAuth 2.0 RFC 6749
- OAuth 2.0 Security Best Practices
- PKCE RFC 7636
- OpenID Connect Core 1.0
- OAuth 2.1 (draft)
- Authentik OAuth Provider Docs
- nuxt-auth-utils Documentation
💡 Notas
- Algunas de estas funcionalidades son opcionales dependiendo del caso de uso
- Para aplicaciones internas simples, la implementación actual puede ser suficiente
- Para aplicaciones de producción orientadas al público, se recomienda implementar al menos la Fase 1 y 2
- PKCE está convirtiéndose en estándar (OAuth 2.1 lo hace obligatorio)
- Considerar usar librerías como
josepara manejo de JWT si se necesita validación de tokens en el cliente