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 mars 20269 min de lecture2.0k Mots|

Authentik OIDC/SSO : Guide d'intégration complet

Authentik est un fournisseur d'identité open source qui vous offre SSO, OIDC, SAML et OAuth2 de niveau entreprise sans la complexité (ou le coût) d'Okta ou d'Auth0. Pour les applications auto-hébergées, il fournit les points de terminaison OIDC d'Authentik, la gestion des utilisateurs, le contrôle d'accès basé sur les groupes et les politiques d'entreprise, le tout sous votre propre infrastructure.

Ce guide couvre une intégration de production Authentik avec Next.js 16 et NestJS 11 : configuration du fournisseur OAuth2, flux de code d'autorisation sécurisé, modèle de code d'échange unique, validation JWT et problèmes subtils qui font ou défont une implémentation d'authentification.

Points clés à retenir

  • Les jetons d'authentification doivent résider dans les cookies HttpOnly - jamais dans les paramètres de stockage local ou de requête
  • Utilisez un code d'échange unique (TTL de 60 secondes) pour transmettre le jeton du rappel au frontend
  • Utilisez toujours AUTHENTIK_INTERNAL_URL pour l'échange de jetons de serveur à serveur (évitez les sauts de réseau)
  • La vérification de l'expiration du code d'échange doit avoir lieu AVANT la suppression du cache (prévention des conditions de concurrence critique)
  • Les secrets client Authentik régénérés via l'API peuvent avoir une incompatibilité de hachage — utilisez directement Django ORM
  • Les revendications JWT doivent inclure organizationId — encodez-le dans le mappage de propriété Authentik
  • Le middleware cookie-parser est requis dans NestJS avant que l'extraction JWT à partir des cookies ne fonctionne
  • Le point de terminaison de découverte OIDC fournit toutes les URL de jeton/informations utilisateur – ne les codez pas en dur

Configuration authentique

Création du fournisseur OAuth2

Dans le panneau d'administration Authentik (/if/admin/) :

  1. Accédez à Applications > Fournisseurs > Créer
  2. Sélectionnez Fournisseur OAuth2/OpenID
  3. Configurez :
  • Nom : ECOSIRE Web OAuth2
  • Flux d'autorisation : default-provider-authorization-implicit-consent
  • Type de client : Confidential
  • ID client : ecosire-web (vous choisissez ceci)
  • URI de redirection :
    https://api.ecosire.com/api/auth/callback
    https://ecosire.com/auth/callback
    
  • Clé de signature : sélectionnez votre certificat par défaut
  • Portées : openid, email, profile
  1. Créez une Application qui utilise ce fournisseur

Mappage de propriétés personnalisées

Pour inclure organizationId dans le JWT, créez un mappage de propriétés :

Dans Authentik admin > Personnalisation > Mappages de propriétés > Créer > Mappage de portée :

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

Ajoutez ce mappage à la liste « Portées » de votre fournisseur OAuth2 et incluez organization dans les portées demandées par votre application.


Backend : module d'authentification NestJS

Stratégie 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';
  }
}

Configuration des 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));

Contrôleur d'authentification

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

Service d'authentification : modèle de code d'échange

Le code d'échange à usage unique est la clé pour éviter le jeton dans l'URL (qui apparaît dans l'historique du navigateur, les journaux du serveur et les en-têtes de référence) :

// 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 : gestionnaire de rappel

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

Résolution du problème de hachage du secret client

Un problème Authentik courant : les secrets client définis via l'API REST présentent parfois une incompatibilité de hachage et échouent à l'échange de jetons OIDC. Le correctif consiste à régénérer le secret directement 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

Mettez ensuite à jour AUTHENTIK_CLIENT_SECRET dans votre .env.local.


Variables de l'environnement de production

# 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

Questions fréquemment posées

Pourquoi utiliser Authentik au lieu de NextAuth.js ?

NextAuth.js est un excellent choix pour les applications simples, mais il couple l'authentification à votre application Next.js. Authentik est un fournisseur d'identité autonome qui fonctionne avec n'importe quel framework : NestJS, applications mobiles, outils tiers. Si vous avez besoin de SSO sur plusieurs applications, souhaitez prendre en charge la connexion d'entreprise SAML ou avez besoin d'une interface utilisateur pour gérer les utilisateurs et les groupes distincte de votre application, Authentik est le meilleur choix.

Quelle est la différence entre OIDC et OAuth2 ?

OAuth2 est un cadre d'autorisation : il définit comment accorder l'accès sans partager les informations d'identification. OIDC (OpenID Connect) est construit sur OAuth2 et ajoute une authentification : il spécifie comment vérifier qui est un utilisateur. Authentik prend en charge les deux. Pour la connexion à votre application, vous souhaitez OIDC (qui vous donne un jeton d'identification avec les revendications de l'utilisateur). OAuth2 seul est destiné aux scénarios d'autorisation tiers tels que "autoriser cette application à accéder à mon Google Drive".

Comment gérer l'actualisation des jetons ?

Stockez le jeton d'actualisation dans un cookie HttpOnly avec un chemin restreint (par exemple, /auth/refresh). Lorsque le jeton d'accès expire, votre API renvoie 401 et votre interface appelle /auth/refresh pour obtenir un nouveau jeton d'accès à l'aide du jeton d'actualisation. Le point de terminaison d'actualisation échange le jeton d'actualisation avec Authentik contre de nouveaux jetons et définit de nouveaux cookies. Gérez le 401 dans votre client API avec une nouvelle tentative automatique après actualisation.

Authentik peut-il gérer les fournisseurs SAML d'entreprise ?

Oui — Authentik prend en charge SAML 2.0 en tant que fournisseur de services et fournisseur d'identité. Pour les entreprises clientes utilisant Okta, Azure AD ou Ping Identity, vous pouvez configurer la fédération SAML afin que les utilisateurs se connectent avec leurs informations d'identification d'entreprise. Authentik traduit les assertions SAML en jetons OIDC, de sorte que votre code d'application n'a pas besoin de gérer directement SAML.

Comment tester les flux d'authentification localement ?

Exécutez Authentik avec Docker Compose localement aux côtés de votre application. Configurez les URI de redirection pour inclure http://localhost:3000/auth/callback. Utilisez le mode test d'Authentik avec un utilisateur local. Pour le flux de code d'échange, le TTL de 60 secondes est suffisamment généreux pour le développement local. Si vous devez déboguer le flux OIDC, le panneau d'administration d'Authentik affiche toutes les tentatives d'échange de jetons dans le journal des événements.


Prochaines étapes

La mise en œuvre d'un système d'authentification sécurisé et prêt pour la production est l'un des défis d'ingénierie les plus critiques pour toute application. ECOSIRE exécute Authentik en production et sert le SSO pour plusieurs applications, avec une authentification basée sur les cookies HttpOnly, des codes d'échange uniques et une intégration OIDC complète entre NestJS et Next.js.

Que vous ayez besoin d'une architecture de système d'authentification, d'un déploiement Authentik ou d'une plate-forme d'entreprise complète, explorez nos services de développement pour voir comment nous pouvons vous aider.

E

Rédigé par

ECOSIRE Research and Development Team

Création de produits numériques de niveau entreprise chez ECOSIRE. Partage d'analyses sur les intégrations Odoo, l'automatisation e-commerce et les solutions d'entreprise propulsées par l'IA.

Discutez sur WhatsApp