diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..c18fe77 --- /dev/null +++ b/TODO.md @@ -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 ` + + + 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 + + +// 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 + + + +``` + +--- + +### 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