# 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 `
Redirecting...
`
})
```
**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
Completando inicio de sesión...
// 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
// 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()
Eliminar Usuario
```
**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
Tu sesión está por expirar
Tu sesión expirará en {{ Math.floor(timeRemaining / 60) }} minutos.
¿Deseas extender tu sesión?
Cerrar
Extender sesión
```
---
### 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
```
**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