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 Mart 20268 dk okuma1.8k Kelime|

Authentik OIDC/SSO: Tam Entegrasyon Kılavuzu

Authentik, Okta veya Auth0'ın karmaşıklığı (veya maliyeti) olmadan size kurumsal düzeyde SSO, OIDC, SAML ve OAuth2 sunan açık kaynaklı bir kimlik sağlayıcısıdır. Şirket içinde barındırılan uygulamalar için Authentik'in OIDC uç noktalarını, kullanıcı yönetimini, grup tabanlı erişim kontrolünü ve kurumsal ilkelerini kendi altyapınız altında sağlar.

Bu kılavuz, Next.js 16 ve NestJS 11 ile üretim Authentik entegrasyonunu kapsar: OAuth2 sağlayıcı yapılandırması, güvenli yetkilendirme kodu akışı, tek seferlik değişim kodu modeli, JWT doğrulaması ve kimlik doğrulama uygulamasını oluşturan veya bozan ince sorunlar.

Önemli Çıkarımlar

  • Kimlik doğrulama belirteçleri HttpOnly çerezlerinde bulunmalıdır; hiçbir zaman localStorage veya sorgu parametrelerinde bulunmamalıdır
  • Belirteci geri aramadan ön uca geçirmek için tek seferlik bir değişim kodu (60 saniyelik TTL) kullanın
  • Sunucular arası jeton alışverişi için her zaman AUTHENTIK_INTERNAL_URL kullanın (ağ atlamalarından kaçının)
  • Değişim kodunun geçerlilik süresi kontrolü, önbellekten silmeden ÖNCE gerçekleştirilmelidir (yarış durumu önleme)
  • API aracılığıyla yeniden oluşturulan Authentik istemci sırlarında karma uyumsuzluğu olabilir; doğrudan Django ORM'yi kullanın
  • JWT talepleri organizationId içermelidir — Authentik özellik eşlemesinde bunu kodlayın
  • Çerezlerden JWT ayıklamanın çalışması için NestJS'de cookie-parser ara katman yazılımı gereklidir
  • OIDC keşif uç noktası tüm belirteç/kullanıcı bilgisi URL'lerini sağlar; bunları sabit kodlamayın

Orijinal Yapılandırma

OAuth2 Sağlayıcısını Oluşturma

Authentik yönetici panelinde (/if/admin/):

  1. Uygulamalar > Sağlayıcılar > Oluştur'a gidin
  2. OAuth2/OpenID Sağlayıcı'yı seçin
  3. Yapılandırın:
  • Ad: ECOSIRE Web OAuth2
  • Yetkilendirme akışı: default-provider-authorization-implicit-consent
  • İstemci türü: Confidential
  • Müşteri Kimliği: ecosire-web (bunu siz seçersiniz)
  • Yönlendirme URI'leri:
    https://api.ecosire.com/api/auth/callback
    https://ecosire.com/auth/callback
    
  • İmzalama Anahtarı: Varsayılan sertifikanızı seçin
  • Kapsamlar: openid, email, profile
  1. Bu sağlayıcıyı kullanan bir Uygulama oluşturun

Özel Özellik Eşlemesi

organizationId öğesini JWT'ye dahil etmek için bir özellik eşlemesi oluşturun:

Authentik admin > Özelleştirme > Özellik Eşlemeleri > Oluştur > Kapsam Eşleme'de:

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

Bu eşlemeyi OAuth2 sağlayıcınızın "Kapsamlar" listesine ekleyin ve uygulamanız tarafından istenen kapsamlara organization ekleyin.


Arka Uç: NestJS Kimlik Doğrulama Modülü

JWT Stratejisi

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

Çerez Yapılandırması

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

Kimlik Doğrulama Denetleyicisi

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

Kimlik Doğrulama Hizmeti: Değişim Kodu Modeli

Tek kullanımlık değişim kodu, URL'deki belirteçlerden (tarayıcı geçmişinde, sunucu günlüklerinde ve yönlendiren başlıklarında görünen) kaçınmanın anahtarıdır:

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

Ön Uç: Geri Arama İşleyicisi

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

İstemci Gizli Karma Sorununu Düzeltme

Yaygın bir Authentik sorunu: REST API aracılığıyla ayarlanan istemci gizli dizilerinde bazen karma uyumsuzluğu olabilir ve OIDC belirteç değişimi başarısız olur. Çözüm, sırrı doğrudan Django ORM aracılığıyla yeniden oluşturmaktır:

# 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

Daha sonra .env.local dosyanızda AUTHENTIK_CLIENT_SECRET öğesini güncelleyin.


Üretim Ortamı Değişkenleri

# 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

Sıkça Sorulan Sorular

Neden NextAuth.js yerine Authentik kullanılmalı?

NextAuth.js, basit uygulamalar için mükemmel bir seçimdir ancak kimlik doğrulamasını Next.js uygulamanızla birleştirir. Authentik, NestJS, mobil uygulamalar, üçüncü taraf araçlar gibi her türlü çerçeveyle çalışan bağımsız bir kimlik sağlayıcıdır. Birden fazla uygulamada SSO'ya ihtiyacınız varsa, SAML kurumsal oturum açmayı desteklemek istiyorsanız veya kullanıcıları ve grupları uygulamanızdan ayrı olarak yönetmek için bir kullanıcı arayüzüne ihtiyacınız varsa Authentik daha iyi bir seçimdir.

OIDC ile OAuth2 arasındaki fark nedir?

OAuth2 bir yetkilendirme çerçevesidir; kimlik bilgilerini paylaşmadan erişimin nasıl verileceğini tanımlar. OIDC (OpenID Connect), OAuth2'nin üzerine kuruludur ve kimlik doğrulaması ekler; bir kullanıcının kim olduğunun nasıl doğrulanacağını belirtir. Authentik her ikisini de destekler. Uygulama girişiniz için OIDC'yi (size kullanıcı taleplerini içeren bir kimlik belirteci verir) istiyorsunuz. Yalnızca OAuth2, "bu uygulamanın Google Drive'ıma erişmesine izin ver" gibi üçüncü taraf yetkilendirme senaryoları içindir.

Jeton yenilemeyi nasıl hallederim?

Yenileme jetonunu kısıtlı bir yola sahip (ör. /auth/refresh) bir HttpOnly çerezinde saklayın. Erişim belirtecinin süresi dolduğunda, API'niz 401 değerini döndürür ve ön ucunuz, yenileme belirtecini kullanarak yeni bir erişim belirteci almak için /auth/refresh öğesini çağırır. Yenileme uç noktası, yenileme belirtecini yeni belirteçler için Authentik ile değiştirir ve yeni çerezler ayarlar. Yenileme sonrasında otomatik yeniden deneme ile API istemcinizdeki 401'i kullanın.

Authentik kurumsal SAML sağlayıcılarını yönetebilir mi?

Evet — Authentik, hem Hizmet Sağlayıcı hem de Kimlik Sağlayıcı olarak SAML 2.0'ı destekler. Okta, Azure AD veya Ping Identity kullanan kurumsal müşteriler için SAML federasyonunu, kullanıcıların kurumsal kimlik bilgileriyle oturum açmasını sağlayacak şekilde yapılandırabilirsiniz. Authentik, SAML iddialarını OIDC belirteçlerine dönüştürür; böylece uygulama kodunuzun SAML'yi doğrudan işlemesine gerek kalmaz.

Kimlik doğrulama akışlarını yerel olarak nasıl test ederim?

Authentik'i uygulamanızın yanında yerel olarak Docker Compose ile çalıştırın. Yönlendirme URI'lerini http://localhost:3000/auth/callback içerecek şekilde yapılandırın. Authentik'in test modunu yerel bir kullanıcıyla kullanın. Değişim kodu akışı açısından 60 saniyelik TTL, yerel kalkınma için yeterince cömerttir. OIDC akışında hata ayıklamanız gerekiyorsa Authentik'in yönetici paneli, Olaylar günlüğündeki tüm token değişim girişimlerini gösterir.


Sonraki Adımlar

Güvenli, üretime hazır bir kimlik doğrulama sisteminin uygulanması, herhangi bir uygulama için en kritik mühendislik zorluklarından biridir. ECOSIRE, Authentik'i HttpOnly çerez tabanlı kimlik doğrulaması, tek seferlik değişim kodları ve NestJS ile Next.js arasında tam OIDC entegrasyonu ile birden fazla uygulama için SSO sunarak üretimde çalıştırıyor.

Kimlik doğrulama sistemi mimarisine, Authentik dağıtımına veya eksiksiz bir kurumsal platforma ihtiyacınız varsa, nasıl yardımcı olabileceğimizi görmek için geliştirme hizmetlerimizi keşfedin.

E

Yazan

ECOSIRE Research and Development Team

ECOSIRE'da kurumsal düzeyde dijital ürünler geliştiriyor. Odoo entegrasyonları, e-ticaret otomasyonu ve yapay zeka destekli iş çözümleri hakkında içgörüler paylaşıyor.

WhatsApp'ta Sohbet Et