Authentik OIDC/SSO: Complete Integration Guide

Complete Authentik OIDC and SSO integration guide: OAuth2 provider setup, Next.js callback handling, NestJS JWT validation, user provisioning, and production configuration.

E
ECOSIRE Research and Development Team
|19 de marzo de 20269 min de lectura2.0k Palabras|

Authentik OIDC/SSO: Guía de integración completa

Authentik es un proveedor de identidades de código abierto que le brinda SSO, OIDC, SAML y OAuth2 de nivel empresarial sin la complejidad (o costo) de Okta o Auth0. Para aplicaciones autohospedadas, proporciona puntos finales OIDC, administración de usuarios, control de acceso basado en grupos y políticas empresariales de Authentik, todo bajo su propia infraestructura.

Esta guía cubre una integración de producción de Authentik con Next.js 16 y NestJS 11: configuración del proveedor OAuth2, el flujo de código de autorización seguro, patrón de código de intercambio único, validación JWT y los problemas sutiles que hacen o deshacen una implementación de autenticación.

Conclusiones clave

  • Los tokens de autenticación deben residir en cookies HttpOnly, nunca en localStorage o parámetros de consulta
  • Utilice un código de intercambio único (TTL de 60 segundos) para pasar el token desde la devolución de llamada al frontend
  • Utilice siempre AUTHENTIK_INTERNAL_URL para el intercambio de tokens de servidor a servidor (evite saltos de red)
  • La verificación de vencimiento del código de intercambio debe realizarse ANTES de eliminarlo del caché (prevención de condición de carrera)
  • Los secretos del cliente Authentik regenerados a través de API pueden tener una discrepancia de hash: use Django ORM directamente
  • Las reclamaciones JWT deben incluir organizationId: codificarlo en la asignación de propiedades de Authentik
  • Se requiere middleware cookie-parser en NestJS antes de que funcione la extracción JWT de las cookies
  • El punto final de descubrimiento OIDC proporciona todas las URL de token/información de usuario; no las codifique

Configuración auténtica

Creando el proveedor OAuth2

En el panel de administración de Authentik (/if/admin/):

  1. Vaya a Aplicaciones > Proveedores > Crear
  2. Seleccione Proveedor OAuth2/OpenID
  3. Configurar:
  • Nombre: ECOSIRE Web OAuth2
  • Flujo de autorización: default-provider-authorization-implicit-consent
  • Tipo de cliente: Confidential
  • ID de cliente: ecosire-web (tú eliges esto)
  • URI de redireccionamiento:
    https://api.ecosire.com/api/auth/callback
    https://ecosire.com/auth/callback
    
  • Clave de firma: seleccione su certificado predeterminado
  • Ámbitos: openid, email, profile
  1. Cree una Aplicación que utilice este proveedor.

Mapeo de propiedades personalizadas

Para incluir organizationId en el JWT, cree una asignación de propiedad:

En administrador de Authentik > Personalización > Asignaciones de propiedades > Crear > Asignación de alcance:

# Name: Organization ID Scope
# Scope name: organization
return {
    "organizationId": request.user.attributes.get("organizationId", str(request.user.pk)),
    "name": request.user.name,
}

Agregue esta asignación a la lista "Ámbitos" de su proveedor de OAuth2 e incluya organization en los ámbitos solicitados por su aplicación.


Backend: Módulo de autenticación NestJS

Estrategia JWT

// auth/strategies/jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { passportJwtSecret } from 'jwks-rsa';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      // Support both Cookie and Bearer token
      jwtFromRequest: ExtractJwt.fromExtractors([
        // Cookie first (web app)
        (request) => {
          return request?.cookies?.ecosire_auth ?? null;
        },
        // Bearer fallback (API clients, mobile)
        ExtractJwt.fromAuthHeaderAsBearerToken(),
      ]),
      ignoreExpiration: false,
      // Validate against Authentik's public JWKS endpoint
      secretOrKeyProvider: passportJwtSecret({
        cache: true,
        rateLimit: true,
        jwksRequestsPerMinute: 5,
        jwksUri: `${process.env.AUTHENTIK_INTERNAL_URL}/application/o/ecosire-web/jwks/`,
      }),
      audience: 'ecosire-web',
      issuer: `${process.env.AUTHENTIK_URL}/application/o/ecosire-web/`,
    });
  }

  async validate(payload: {
    sub: string;
    email: string;
    name: string;
    organizationId?: string;
    groups?: string[];
  }) {
    if (!payload.sub) {
      throw new UnauthorizedException('Invalid token');
    }

    // Map Authentik claims to your internal user type
    return {
      sub: payload.sub,
      email: payload.email,
      name: payload.name,
      organizationId: payload.organizationId ?? payload.sub,
      role: this.mapGroupsToRole(payload.groups ?? []),
    };
  }

  private mapGroupsToRole(groups: string[]): 'admin' | 'support' | 'user' {
    if (groups.includes('ecosire-admins')) return 'admin';
    if (groups.includes('ecosire-support')) return 'support';
    return 'user';
  }
}

Configuración de cookies

// main.ts
import cookieParser from 'cookie-parser';

const app = await NestFactory.create(AppModule);

// cookie-parser MUST be registered before JWT strategy runs
app.use(cookieParser(process.env.COOKIE_SECRET));

Controlador de autenticación

// auth/auth.controller.ts
import {
  Controller,
  Get,
  Post,
  Query,
  Res,
  Req,
  Body,
  UnauthorizedException,
} from '@nestjs/common';
import { Response, Request } from 'express';
import { Public } from './decorators/public.decorator';
import { AuthService } from './auth.service';

const COOKIE_OPTS = {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'lax' as const,
  path: '/',
  maxAge: 60 * 60 * 24 * 7 * 1000, // 7 days
};

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @Get('login')
  @Public()
  login(@Res() res: Response) {
    const params = new URLSearchParams({
      client_id: process.env.AUTHENTIK_CLIENT_ID!,
      redirect_uri: `${process.env.API_URL}/auth/callback`,
      response_type: 'code',
      scope: 'openid email profile organization',
      state: this.authService.generateState(),
    });

    const authUrl = `${process.env.AUTHENTIK_URL}/application/o/authorize/?${params}`;
    return res.redirect(authUrl);
  }

  @Get('callback')
  @Public()
  async callback(
    @Query('code') code: string,
    @Query('state') state: string,
    @Res() res: Response
  ) {
    // Exchange authorization code for tokens
    const { accessToken, refreshToken } = await this.authService.exchangeCode(code, state);

    // Create a one-time exchange code for the frontend
    // This avoids putting the token in the redirect URL
    const exchangeCode = await this.authService.createExchangeCode(accessToken, refreshToken);

    // Redirect to frontend with the one-time code (not the token itself)
    return res.redirect(
      `${process.env.FRONTEND_URL}/auth/callback?exchange=${exchangeCode}`
    );
  }

  @Post('exchange')
  @Public()
  async exchangeTokens(
    @Body() body: { code: string },
    @Res() res: Response
  ) {
    // Frontend exchanges the one-time code for HttpOnly cookies
    const tokens = await this.authService.redeemExchangeCode(body.code);

    if (!tokens) {
      throw new UnauthorizedException('Invalid or expired exchange code');
    }

    // Set HttpOnly cookies — tokens never in response body
    res.cookie('ecosire_auth', tokens.accessToken, COOKIE_OPTS);
    res.cookie('ecosire_refresh', tokens.refreshToken, {
      ...COOKIE_OPTS,
      path: '/auth/refresh', // Refresh token only sent to refresh endpoint
    });

    return res.json({ success: true });
  }

  @Get('session')
  async getSession(@Req() req: Request) {
    // JWT guard already validated the cookie, user attached to req
    return req.user; // sub, email, name, role, organizationId
  }

  @Post('logout')
  async logout(@Res() res: Response) {
    // Clear cookies with the SAME options used to set them
    res.clearCookie('ecosire_auth', COOKIE_OPTS);
    res.clearCookie('ecosire_refresh', {
      ...COOKIE_OPTS,
      path: '/auth/refresh',
    });

    // Optionally: End Authentik session
    const logoutUrl = `${process.env.AUTHENTIK_URL}/application/o/ecosire-web/end-session/`;

    return res.json({ logoutUrl });
  }
}

Servicio de autenticación: patrón de código de intercambio

El código de intercambio único es la clave para evitar el token en URL (que aparece en el historial del navegador, los registros del servidor y los encabezados de referencia):

// auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { Redis } from 'ioredis';
import { nanoid } from 'nanoid';
import axios from 'axios';

@Injectable()
export class AuthService {
  constructor(private redis: Redis) {}

  async exchangeCode(code: string, state: string) {
    // Verify state matches (CSRF protection)
    const storedState = await this.redis.get(`auth:state:${state}`);
    if (!storedState) {
      throw new UnauthorizedException('Invalid state parameter');
    }
    await this.redis.del(`auth:state:${state}`);

    // Exchange authorization code for tokens using internal URL
    // AUTHENTIK_INTERNAL_URL avoids going through Nginx/Cloudflare for
    // server-to-server calls inside the same network
    const response = await axios.post(
      `${process.env.AUTHENTIK_INTERNAL_URL}/application/o/token/`,
      new URLSearchParams({
        grant_type: 'authorization_code',
        code,
        redirect_uri: `${process.env.API_URL}/auth/callback`,
        client_id: process.env.AUTHENTIK_CLIENT_ID!,
        client_secret: process.env.AUTHENTIK_CLIENT_SECRET!,
      }),
      { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
    );

    return {
      accessToken: response.data.access_token,
      refreshToken: response.data.refresh_token,
    };
  }

  async createExchangeCode(
    accessToken: string,
    refreshToken: string
  ): Promise<string> {
    const code = nanoid(32);
    const payload = JSON.stringify({ accessToken, refreshToken });

    // 60-second TTL — enough for the redirect to complete
    await this.redis.setex(`auth:exchange:${code}`, 60, payload);

    return code;
  }

  async redeemExchangeCode(code: string) {
    const key = `auth:exchange:${code}`;

    // Check expiry BEFORE deleting (prevents race condition)
    const ttl = await this.redis.ttl(key);
    if (ttl <= 0) {
      return null; // Already expired or doesn't exist
    }

    const data = await this.redis.getdel(key); // Atomic get-and-delete
    if (!data) return null;

    return JSON.parse(data);
  }

  generateState(): string {
    const state = nanoid(32);
    // Store state with 10-minute TTL
    this.redis.setex(`auth:state:${state}`, 600, '1');
    return state;
  }

  async upsertUser(payload: {
    sub: string;
    email: string;
    name: string;
    organizationId: string;
  }) {
    const [user] = await db
      .insert(users)
      .values({
        id: payload.sub,
        email: payload.email,
        name: payload.name,
        organizationId: payload.organizationId,
      })
      .onConflictDoUpdate({
        target: users.id,
        set: {
          email: payload.email,
          name: payload.name,
          lastLoginAt: new Date(),
        },
      })
      .returning();

    return user;
  }
}

Interfaz: controlador de devolución de llamada

// app/auth/callback/page.tsx — Next.js page
'use client';

import { useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';

export default function AuthCallbackPage() {
  const router = useRouter();
  const params = useSearchParams();

  useEffect(() => {
    const exchangeCode = params.get('exchange');
    const redirectTo = params.get('redirect') ?? '/dashboard';

    if (!exchangeCode) {
      router.push('/auth/login?error=no_code');
      return;
    }

    // Prevent open redirect
    const safeRedirect = redirectTo.startsWith('/') && !redirectTo.startsWith('//')
      ? redirectTo
      : '/dashboard';

    // Exchange one-time code for HttpOnly cookies
    fetch('/api/auth/exchange', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ code: exchangeCode }),
      credentials: 'include', // Required for cookie setting
    })
      .then((res) => {
        if (res.ok) {
          router.push(safeRedirect);
        } else {
          router.push('/auth/login?error=exchange_failed');
        }
      })
      .catch(() => router.push('/auth/login?error=network'));
  }, []);

  return (
    <div className="flex items-center justify-center min-h-screen">
      <p>Signing you in...</p>
    </div>
  );
}

Solucionar el problema del hash secreto del cliente

Un problema común de Authentik: los secretos de cliente establecidos a través de la API REST a veces tienen una discrepancia de hash y fallan en el intercambio de tokens OIDC. La solución es regenerar el secreto a través de Django ORM directamente:

# Run through the authentik container's shell
cat > /tmp/regen_secret.py << 'EOF'
from authentik.providers.oauth2.models import OAuth2Provider
import secrets

provider = OAuth2Provider.objects.get(name="ECOSIRE Web OAuth2")
new_secret = secrets.token_urlsafe(64)
provider.client_secret = new_secret
provider.save()
print(f"New secret: {new_secret}")
EOF

docker exec -i authentik-worker /lifecycle/ak shell < /tmp/regen_secret.py

Luego actualice AUTHENTIK_CLIENT_SECRET en su .env.local.


Variables del entorno de producción

# Authentik
AUTHENTIK_URL=https://auth.ecosire.com
AUTHENTIK_INTERNAL_URL=http://localhost:9000  # Server-to-server
AUTHENTIK_CLIENT_ID=ecosire-web
AUTHENTIK_CLIENT_SECRET=your-generated-secret

# App URLs
API_URL=https://api.ecosire.com/api
FRONTEND_URL=https://ecosire.com

# Cookie security
COOKIE_SECRET=random-32-char-string-for-signing
NODE_ENV=production

Preguntas frecuentes

¿Por qué utilizar Authentik en lugar de NextAuth.js?

NextAuth.js es una excelente opción para aplicaciones simples, pero combina la autenticación con su aplicación Next.js. Authentik es un proveedor de identidad independiente que funciona con cualquier marco: NestJS, aplicaciones móviles y herramientas de terceros. Si necesita SSO en varias aplicaciones, desea admitir el inicio de sesión empresarial SAML o necesita una interfaz de usuario para administrar usuarios y grupos separados de su aplicación, Authentik es la mejor opción.

¿Cuál es la diferencia entre OIDC y OAuth2?

OAuth2 es un marco de autorización: define cómo otorgar acceso sin compartir credenciales. OIDC (OpenID Connect) se basa en OAuth2 y agrega autenticación: especifica cómo verificar quién es un usuario. Authentik admite ambos. Para iniciar sesión en su aplicación, desea OIDC (que le brinda un token de identificación con reclamos de usuario). OAuth2 por sí solo es para escenarios de autorización de terceros como "permitir que esta aplicación acceda a mi Google Drive".

¿Cómo manejo la actualización del token?

Almacene el token de actualización en una cookie HttpOnly con una ruta restringida (por ejemplo, /auth/refresh). Cuando el token de acceso caduca, su API devuelve 401 y su interfaz llama a /auth/refresh para obtener un nuevo token de acceso utilizando el token de actualización. El punto final de actualización intercambia el token de actualización con Authentik por nuevos tokens y establece nuevas cookies. Maneje el 401 en su cliente API con reintento automático después de la actualización.

¿Puede Authentik manejar proveedores SAML empresariales?

Sí, Authentik admite SAML 2.0 como proveedor de servicios y proveedor de identidades. Para los clientes empresariales que utilizan Okta, Azure AD o Ping Identity, pueden configurar la federación SAML para que los usuarios inicien sesión con sus credenciales corporativas. Authentik traduce las afirmaciones de SAML en tokens OIDC, por lo que no es necesario que el código de su aplicación maneje SAML directamente.

¿Cómo pruebo los flujos de autenticación localmente?

Ejecute Authentik con Docker Compose localmente junto con su aplicación. Configure los URI de redireccionamiento para incluir http://localhost:3000/auth/callback. Utilice el modo de prueba de Authentik con un usuario local. Para el flujo de código de intercambio, el TTL de 60 segundos es lo suficientemente generoso para el desarrollo local. Si necesita depurar el flujo OIDC, el panel de administración de Authentik muestra todos los intentos de intercambio de tokens en el registro de eventos.


Próximos pasos

La implementación de un sistema de autenticación seguro y listo para producción es uno de los desafíos de ingeniería más críticos para cualquier aplicación. ECOSIRE ejecuta Authentik en producción y ofrece SSO para múltiples aplicaciones, con autenticación basada en cookies HttpOnly, códigos de intercambio únicos e integración OIDC completa entre NestJS y Next.js.

Ya sea que necesite una arquitectura de sistema de autenticación, implementación de Authentik o una plataforma empresarial completa, explore nuestros servicios de desarrollo para ver cómo podemos ayudarlo.

E

Escrito por

ECOSIRE Research and Development Team

Construyendo productos digitales de nivel empresarial en ECOSIRE. Compartiendo perspectivas sobre integraciones Odoo, automatización de eCommerce y soluciones empresariales impulsadas por IA.

Chatea en whatsapp