Files
seguidorDeLotes/TODO.md
josedario87 e28c6b925e
All checks were successful
build-and-deploy / build (push) Successful in 9s
build-and-deploy / deploy (push) Successful in 3s
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.
2025-10-11 19:27:48 -06:00

22 KiB

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:

// 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:


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:

// 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:


🟡 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:

// 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:

// 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:

// 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:

// 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:

// 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:

// 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:

// 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:

// 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:

// 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:

// 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:

// 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:

// 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:

// 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):

  1. Redirect After Login - UX básica esperada
  2. Error Messages - Debugging y mejor UX
  3. RBAC - Control de acceso por grupos

Nice to Have (Media/Baja prioridad):

  1. PKCE - Seguridad adicional (cada vez más recomendado)
  2. Loading States - Mejor UX durante OAuth flow
  3. Session Timeout Warning - UX avanzada
  4. 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


💡 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