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 março de 20269 min de leitura2.0k Palavras|

Authentik OIDC/SSO: Guia completo de integração

Authentik é um provedor de identidade de código aberto que oferece SSO, OIDC, SAML e OAuth2 de nível empresarial sem a complexidade (ou custo) do Okta ou Auth0. Para aplicativos auto-hospedados, ele fornece endpoints OIDC da Authentik, gerenciamento de usuários, controle de acesso baseado em grupo e políticas corporativas – tudo em sua própria infraestrutura.

Este guia cobre uma integração de produção do Authentik com Next.js 16 e NestJS 11: configuração do provedor OAuth2, o fluxo de código de autorização seguro, padrão de código de troca único, validação JWT e os problemas sutis que determinam ou interrompem uma implementação de autenticação.

Principais conclusões

  • Os tokens de autenticação devem residir em cookies HttpOnly — nunca em localStorage ou parâmetros de consulta
  • Use um código de troca único (TTL de 60 segundos) para passar o token do retorno de chamada para o frontend
  • Sempre use AUTHENTIK_INTERNAL_URL para troca de tokens de servidor para servidor (evite saltos de rede)
  • A verificação de expiração do código de troca deve acontecer ANTES da exclusão do cache (prevenção de condição de corrida)
  • Os segredos do cliente Authentik regenerados via API podem ter uma incompatibilidade de hash - use Django ORM diretamente
  • As declarações JWT devem incluir organizationId — codifique-o no mapeamento de propriedades Authentik
  • O middleware cookie-parser é necessário no NestJS antes que a extração JWT dos cookies funcione
  • O endpoint de descoberta OIDC fornece todos os URLs de token/informações do usuário — não os codifique

Configuração autêntica

Criando o provedor OAuth2

No painel de administração do Authentik (/if/admin/):

  1. Vá para Aplicativos > Provedores > Criar
  2. Selecione Provedor OAuth2/OpenID
  3. Configurar:
  • Nome: ECOSIRE Web OAuth2
  • Fluxo de autorização: default-provider-authorization-implicit-consent
  • Tipo de cliente: Confidential
  • ID do cliente: ecosire-web (você escolhe isso)
  • URIs de redirecionamento:
    https://api.ecosire.com/api/auth/callback
    https://ecosire.com/auth/callback
    
  • Chave de assinatura: selecione seu certificado padrão
  • Escopos: openid, email, profile
  1. Crie um Aplicativo que use este provedor

Mapeamento de propriedades personalizadas

Para incluir organizationId no JWT, crie um mapeamento de propriedades:

No administrador do Authentik > Personalização > Mapeamentos de propriedades > Criar > Mapeamento de escopo:

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

Adicione esse mapeamento à lista de "Escopos" do seu provedor OAuth2 e inclua organization nos escopos solicitados pelo seu aplicativo.


Back-end: Módulo de autenticação NestJS

Estratégia 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';
  }
}

Configuração 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 autenticação

// 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 });
  }
}

Serviço de autenticação: padrão de código Exchange

O código de troca único é a chave para evitar token-in-URL (que aparece no histórico do navegador, logs do servidor e cabeçalhos de referência):

// 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;
  }
}

Frontend: manipulador de retorno de chamada

// 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>
  );
}

Corrigindo o problema de hashing do segredo do cliente

Um problema comum do Authentik: os segredos do cliente definidos por meio da API REST às vezes apresentam incompatibilidade de hash e falham na troca de token OIDC. A correção é regenerar o segredo diretamente via Django ORM:

# 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

Em seguida, atualize AUTHENTIK_CLIENT_SECRET em seu .env.local.


Variáveis de ambiente de produção

# 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

Perguntas frequentes

Por que usar Authentik em vez de NextAuth.js?

NextAuth.js é uma ótima opção para aplicativos simples, mas combina autenticação com seu aplicativo Next.js. Authentik é um provedor de identidade independente que funciona com qualquer estrutura – NestJS, aplicativos móveis, ferramentas de terceiros. Se você precisar de SSO em vários aplicativos, quiser oferecer suporte ao login corporativo SAML ou precisar de uma UI para gerenciar usuários e grupos separados do seu aplicativo, o Authentik é a melhor escolha.

Qual é a diferença entre OIDC e OAuth2?

OAuth2 é uma estrutura de autorização — define como conceder acesso sem compartilhar credenciais. OIDC (OpenID Connect) é construído sobre OAuth2 e adiciona autenticação – especifica como verificar quem é um usuário. Authentik oferece suporte a ambos. Para o login do seu aplicativo, você deseja o OIDC (que fornece um token de ID com declarações do usuário). Somente o OAuth2 serve para cenários de autorização de terceiros, como "permitir que este aplicativo acesse meu Google Drive".

Como lidar com a atualização do token?

Armazene o token de atualização em um cookie HttpOnly com um caminho restrito (por exemplo, /auth/refresh). Quando o token de acesso expira, sua API retorna 401 e seu frontend chama /auth/refresh para obter um novo token de acesso usando o token de atualização. O endpoint de atualização troca o token de atualização com o Authentik por novos tokens e define novos cookies. Lide com o 401 em seu cliente API com nova tentativa automática após atualização.

O Authentik pode lidar com provedores SAML corporativos?

Sim — Authentik oferece suporte a SAML 2.0 como provedor de serviços e provedor de identidade. Para clientes empresariais que usam Okta, Azure AD ou Ping Identity, você pode configurar a federação SAML para que os usuários façam login com suas credenciais corporativas. Authentik traduz asserções SAML em tokens OIDC, para que o código do seu aplicativo não precise lidar diretamente com SAML.

Como faço para testar fluxos de autenticação localmente?

Execute o Authentik com Docker Compose localmente junto com seu aplicativo. Configure URIs de redirecionamento para incluir http://localhost:3000/auth/callback. Use o modo de teste do Authentik com um usuário local. Para o fluxo do código de troca, o TTL de 60 segundos é generoso o suficiente para o desenvolvimento local. Se você precisar depurar o fluxo OIDC, o painel de administração do Authentik mostrará todas as tentativas de troca de tokens no log de eventos.


Próximas etapas

Implementar um sistema de autenticação seguro e pronto para produção é um dos desafios de engenharia mais críticos para qualquer aplicação. ECOSIRE executa Authentik em produção servindo SSO para vários aplicativos, com autenticação baseada em cookie HttpOnly, códigos de troca únicos e integração OIDC completa entre NestJS e Next.js.

Se você precisa de uma arquitetura de sistema de autenticação, implantação do Authentik ou uma plataforma corporativa completa, explore nossos serviços de desenvolvimento para ver como podemos ajudar.

E

Escrito por

ECOSIRE Research and Development Team

Construindo produtos digitais de nível empresarial na ECOSIRE. Compartilhando insights sobre integrações Odoo, automação de e-commerce e soluções de negócios com IA.

Converse no WhatsApp