Add OAuth2/OIDC improvements roadmap
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.
This commit is contained in:
799
TODO.md
Normal file
799
TODO.md
Normal file
@@ -0,0 +1,799 @@
|
||||
# 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:**
|
||||
```typescript
|
||||
// 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:**
|
||||
- [RFC 6749 - Section 10.12](https://tools.ietf.org/html/rfc6749#section-10.12)
|
||||
- [OAuth 2.0 Security Best Practices](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics)
|
||||
|
||||
---
|
||||
|
||||
### 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:**
|
||||
```typescript
|
||||
// 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:**
|
||||
- [RFC 7636 - PKCE](https://tools.ietf.org/html/rfc7636)
|
||||
- [OAuth 2.1 (draft) - PKCE obligatorio](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-07)
|
||||
|
||||
---
|
||||
|
||||
## 🟡 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:**
|
||||
```typescript
|
||||
// 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:**
|
||||
```typescript
|
||||
// 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:**
|
||||
```typescript
|
||||
// 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_token` en la sesión
|
||||
- Authentik debe tener configurada la URL de post-logout redirect
|
||||
- Opcionalmente agregar parámetro `state` para 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:**
|
||||
```typescript
|
||||
// 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:**
|
||||
```typescript
|
||||
// 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:**
|
||||
```typescript
|
||||
// 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:**
|
||||
```typescript
|
||||
// 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:**
|
||||
```typescript
|
||||
// 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:**
|
||||
```typescript
|
||||
// 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:**
|
||||
```typescript
|
||||
// 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:**
|
||||
```typescript
|
||||
// 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:**
|
||||
```typescript
|
||||
// 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:**
|
||||
```typescript
|
||||
// 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):
|
||||
1. ✅ **State Parameter** - Seguridad crítica (CSRF protection)
|
||||
2. ✅ **Refresh Tokens** - Mejor UX, evita re-autenticación constante
|
||||
3. ✅ **Token Storage & Expiration** - Base para refresh tokens
|
||||
4. ✅ **SSO Logout** - Logout completo en Authentik
|
||||
|
||||
### Importante (Alta prioridad):
|
||||
5. ✅ **Redirect After Login** - UX básica esperada
|
||||
6. ✅ **Error Messages** - Debugging y mejor UX
|
||||
7. ✅ **RBAC** - Control de acceso por grupos
|
||||
|
||||
### Nice to Have (Media/Baja prioridad):
|
||||
8. ⭕ **PKCE** - Seguridad adicional (cada vez más recomendado)
|
||||
9. ⭕ **Loading States** - Mejor UX durante OAuth flow
|
||||
10. ⭕ **Session Timeout Warning** - UX avanzada
|
||||
11. ⭕ **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](https://tools.ietf.org/html/rfc6749)
|
||||
- [OAuth 2.0 Security Best Practices](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics)
|
||||
- [PKCE RFC 7636](https://tools.ietf.org/html/rfc7636)
|
||||
- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
|
||||
- [OAuth 2.1 (draft)](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-07)
|
||||
- [Authentik OAuth Provider Docs](https://docs.goauthentik.io/docs/providers/oauth2/)
|
||||
- [nuxt-auth-utils Documentation](https://github.com/Atinux/nuxt-auth-utils)
|
||||
|
||||
---
|
||||
|
||||
## 💡 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 `jose` para manejo de JWT si se necesita validación de tokens en el cliente
|
||||
Reference in New Issue
Block a user