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.
800 lines
22 KiB
Markdown
800 lines
22 KiB
Markdown
# 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
|