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 مارس 20269 دقائق قراءة1.9k كلمات|

Authentik OIDC/SSO: دليل التكامل الكامل

Authentik هو موفر هوية مفتوح المصدر يمنحك تسجيل الدخول الموحد (SSO) وOIDC وSAML وOAuth2 على مستوى المؤسسة دون تعقيد (أو تكلفة) Okta أو Auth0. بالنسبة للتطبيقات ذاتية الاستضافة، فهي توفر نقاط نهاية OIDC الخاصة بـ Authentik، وإدارة المستخدم، والتحكم في الوصول على أساس المجموعة، وسياسات المؤسسة - كل ذلك ضمن البنية الأساسية الخاصة بك.

يغطي هذا الدليل تكامل Authentik للإنتاج مع Next.js 16 وNestJS 11: تكوين موفر OAuth2، وتدفق رمز التفويض الآمن، ونمط رمز التبادل لمرة واحدة، والتحقق من صحة JWT، والمشكلات الدقيقة التي تؤدي إلى تنفيذ المصادقة أو تعطيلها.

الوجبات الرئيسية

  • يجب أن تكون رموز المصادقة موجودة في ملفات تعريف الارتباط HttpOnly - وليس في التخزين المحلي أو معلمات الاستعلام أبدًا
  • استخدم رمز التبادل لمرة واحدة (TTL لمدة 60 ثانية) لتمرير الرمز المميز من رد الاتصال إلى الواجهة الأمامية
  • استخدم دائمًا AUTHENTIK_INTERNAL_URL لتبادل الرموز المميزة من خادم إلى خادم (تجنب قفزات الشبكة)
  • يجب أن يتم التحقق من انتهاء صلاحية رمز التبادل قبل الحذف من ذاكرة التخزين المؤقت (منع حالة السباق)
  • قد تحتوي أسرار عميل Authentik التي تم تجديدها عبر واجهة برمجة التطبيقات (API) على عدم تطابق التجزئة - استخدم Django ORM مباشرة
  • يجب أن تتضمن مطالبات JWT organizationId - قم بتشفيرها في تعيين خاصية Authentik
  • cookie-parser الوسيطة مطلوبة في NestJS قبل أن يعمل استخراج JWT من ملفات تعريف الارتباط
  • توفر نقطة نهاية اكتشاف OIDC جميع عناوين URL الخاصة بالرمز المميز/معلومات المستخدم - ولا تقم بترميزها

تكوين المصادقة

إنشاء موفر OAuth2

في لوحة إدارة Authentik (/if/admin/):

  1. انتقل إلى التطبيقات > مقدمو الخدمة > إنشاء
  2. حدد موفر OAuth2/OpenID
  3. التكوين:
  • الاسم: ECOSIRE Web OAuth2
  • تدفق التفويض: default-provider-authorization-implicit-consent
  • نوع العميل: Confidential
  • معرف العميل: ecosire-web (اخترت هذا)
  • إعادة توجيه عناوين URI:
    https://api.ecosire.com/api/auth/callback
    https://ecosire.com/auth/callback
    
  • مفتاح التوقيع: حدد شهادتك الافتراضية
  • النطاقات: openid، email، profile
  1. قم بإنشاء تطبيق يستخدم هذا الموفر

رسم الخرائط المخصصة للعقارات

لتضمين organizationId في JWT، أنشئ تعيين خاصية:

في مسؤول Authentik > التخصيص > تعيينات الخصائص > إنشاء > تعيين النطاق:

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

أضف هذا التعيين إلى قائمة "النطاقات" الخاصة بموفر OAuth2 وقم بتضمين organization في النطاقات التي يطلبها تطبيقك.


الواجهة الخلفية: وحدة مصادقة NestJS

استراتيجية 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';
  }
}

تكوين ملفات تعريف الارتباط

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

وحدة تحكم المصادقة

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

خدمة المصادقة: نمط رمز التبادل

رمز التبادل لمرة واحدة هو المفتاح لتجنب الرمز المميز في عنوان URL (الذي يظهر في سجل المتصفح، وسجلات الخادم، ورؤوس المُحيل):

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

الواجهة الأمامية: معالج رد الاتصال

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

إصلاح مشكلة التجزئة السرية للعميل

مشكلة Authentik شائعة: أحيانًا ما تكون أسرار العميل التي تم تعيينها عبر REST API غير متطابقة في التجزئة وتفشل في تبادل رمز OIDC. الإصلاح هو إعادة إنشاء السر عبر 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

ثم قم بتحديث AUTHENTIK_CLIENT_SECRET في .env.local الخاص بك.


متغيرات بيئة الإنتاج

# 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

الأسئلة المتداولة

لماذا تستخدم Authentik بدلاً من NextAuth.js؟

يعد NextAuth.js خيارًا رائعًا للتطبيقات البسيطة، ولكنه يجمع بين المصادقة وتطبيق Next.js الخاص بك. Authentik هو موفر هوية مستقل يعمل مع أي إطار عمل — NestJS، وتطبيقات الهاتف المحمول، وأدوات الطرف الثالث. إذا كنت بحاجة إلى تسجيل الدخول الموحد (SSO) عبر تطبيقات متعددة، أو ترغب في دعم تسجيل الدخول إلى مؤسسة SAML، أو تحتاج إلى واجهة مستخدم لإدارة المستخدمين والمجموعات بشكل منفصل عن تطبيقك، فإن Authentik هو الخيار الأفضل.

ما الفرق بين OIDC وOAuth2؟

OAuth2 هو إطار عمل للتفويض - فهو يحدد كيفية منح الوصول دون مشاركة بيانات الاعتماد. تم إنشاء OIDC (OpenID Connect) أعلى OAuth2 ويضيف المصادقة - فهو يحدد كيفية التحقق من هوية المستخدم. Authentik يدعم كليهما. لتسجيل الدخول إلى التطبيق الخاص بك، فأنت تريد OIDC (الذي يمنحك رمز معرف مع مطالبات المستخدم). OAuth2 وحده مخصص لسيناريوهات ترخيص الجهات الخارجية مثل "السماح لهذا التطبيق بالوصول إلى Google Drive الخاص بي".

كيف أتعامل مع تحديث الرمز المميز؟

قم بتخزين رمز التحديث في ملف تعريف الارتباط HttpOnly بمسار مقيد (على سبيل المثال، /auth/refresh). عند انتهاء صلاحية رمز الوصول، تقوم واجهة برمجة التطبيقات الخاصة بك بإرجاع 401، وتستدعي الواجهة الأمامية /auth/refresh للحصول على رمز وصول جديد باستخدام رمز التحديث. تقوم نقطة نهاية التحديث بتبادل رمز التحديث مع Authentik للحصول على رموز مميزة جديدة وتعيين ملفات تعريف ارتباط جديدة. تعامل مع 401 في عميل API الخاص بك من خلال إعادة المحاولة التلقائية بعد التحديث.

هل يستطيع Authentik التعامل مع موفري SAML للمؤسسات؟

نعم — يدعم Authentik SAML 2.0 كمقدم خدمة وموفر هوية. بالنسبة لعملاء المؤسسات الذين يستخدمون Okta أو Azure AD أو Ping Identity، يمكنك تكوين اتحاد SAML حتى يقوم المستخدمون بتسجيل الدخول باستخدام بيانات اعتماد الشركة الخاصة بهم. يقوم Authentik بترجمة تأكيدات SAML إلى رموز OIDC المميزة، لذلك لا يحتاج رمز التطبيق الخاص بك إلى التعامل مع SAML مباشرة.

كيف يمكنني اختبار تدفقات المصادقة محليًا؟

قم بتشغيل Authentik باستخدام Docker Compose محليًا بجانب تطبيقك. قم بتكوين عناوين URI لإعادة التوجيه لتتضمن http://localhost:3000/auth/callback. استخدم وضع اختبار Authentik مع مستخدم محلي. بالنسبة لتدفق كود التبادل، فإن TTL لمدة 60 ثانية يعتبر سخيًا بما يكفي للتنمية المحلية. إذا كنت بحاجة إلى تصحيح أخطاء تدفق OIDC، فستعرض لوحة إدارة Authentik جميع محاولات تبادل الرمز المميز في سجل الأحداث.


الخطوات التالية

يعد تنفيذ نظام مصادقة آمن وجاهز للإنتاج أحد التحديات الهندسية الأكثر أهمية لأي تطبيق. تقوم ECOSIRE بتشغيل Authentik في الإنتاج الذي يخدم تسجيل الدخول الموحد (SSO) لتطبيقات متعددة، مع المصادقة المستندة إلى ملف تعريف الارتباط HttpOnly، ورموز التبادل لمرة واحدة، وتكامل OIDC الكامل بين NestJS وNext.js.

سواء كنت بحاجة إلى بنية نظام مصادقة، أو نشر Authentik، أو نظام أساسي مؤسسي كامل، استكشف خدمات التطوير لدينا لترى كيف يمكننا مساعدتك.

مشاركة:
E

بقلم

ECOSIRE Research and Development Team

بناء منتجات رقمية بمستوى المؤسسات في ECOSIRE. مشاركة رؤى حول تكاملات Odoo وأتمتة التجارة الإلكترونية وحلول الأعمال المدعومة بالذكاء الاصطناعي.

الدردشة على الواتساب