# 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