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
|2026年3月19日6 分钟阅读1.3k 字数|

Authentik OIDC/SSO:完整集成指南

Authentik 是一个开源身份提供商,可为您提供企业级 SSO、OIDC、SAML 和 OAuth2,而没有 Okta 或 Auth0 的复杂性(或成本)。对于自托管应用程序,它提供 Authentik 的 OIDC 端点、用户管理、基于组的访问控制和企业策略 - 所有这些都在您自己的基础架构下。

本指南涵盖了 Authentik 与 Next.js 16 和 NestJS 11 的生产集成:OAuth2 提供程序配置、安全授权代码流、一次性交换代码模式、JWT 验证以及影响或破坏身份验证实现的微妙问题。

要点

  • 身份验证令牌必须存在于 HttpOnly cookie 中 — 切勿存在于 localStorage 或查询参数中
  • 使用一次性交换代码(60秒TTL)将令牌从回调传递到前端
  • 始终使用 AUTHENTIK_INTERNAL_URL 进行服务器到服务器的令牌交换(避免网络跳跃)
  • 交换代码过期检查必须在从缓存中删除之前进行(防止竞争条件)
  • 通过 API 重新生成的 Authentik 客户端密钥可能存在哈希不匹配 - 直接使用 Django ORM
  • JWT 声明必须包含 organizationId — 将其编码到 Authentik 属性映射中
  • 在从 cookie 中提取 JWT 之前,NestJS 中需要 cookie-parser 中间件
  • OIDC 发现端点提供所有令牌/用户信息 URL — 不要对它们进行硬编码

真实配置

创建 OAuth2 提供程序

在 Authentik 管理面板 (/if/admin/) 中:

  1. 转到 应用程序 > 提供程序 > 创建
  2. 选择 OAuth2/OpenID 提供商
  3. 配置:
  • 名称ECOSIRE Web OAuth2
  • 授权流程default-provider-authorization-implicit-consent
  • 客户端类型Confidential
  • 客户端 IDecosire-web(您选择此)
  • 重定向 URI
    https://api.ecosire.com/api/auth/callback
    https://ecosire.com/auth/callback
    
  • 签名密钥:选择您的默认证书
  • 范围openidemailprofile
  1. 创建一个使用此提供程序的应用程序

自定义属性映射

要在 JWT 中包含 organizationId,请创建属性映射:

在 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 Auth 模块

智威汤逊策略

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

身份验证服务:交换 Code Pattern

一次性交换代码是避免 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

然后更新 .env.local 中的 AUTHENTIK_CLIENT_SECRET


生产环境变量

# 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 企业登录,或者需要一个 UI 来管理与应用程序分开的用户和组,Authentik 是更好的选择。

OIDC 和 OAuth2 有什么区别?

OAuth2 是一个授权框架——它定义了如何在不共享凭据的情况下授予访问权限。 OIDC (OpenID Connect) 构建在 OAuth2 之上并添加了身份验证 - 它指定如何验证用户是谁。 Authentik 两者都支持。对于您的应用程序登录,您需要 OIDC(它为您提供带有用户声明的 ID 令牌)。 OAuth2 仅适用于第三方授权场景,例如“允许此应用程序访问我的 Google 云端硬盘”。

如何处理令牌刷新?

将刷新令牌存储在具有受限路径(例如 /auth/refresh)的 HttpOnly cookie 中。当访问令牌过期时,您的 API 返回 401,并且您的前端调用 /auth/refresh 以使用刷新令牌获取新的访问令牌。刷新端点与 Authentik 交换刷新令牌以获取新令牌并设置新的 cookie。通过刷新后自动重试来处理 API 客户端中的 401。

Authentik 可以处理企业 SAML 提供商吗?

是 - Authentik 支持 SAML 2.0 作为服务提供商和身份提供商。对于使用 Okta、Azure AD 或 Ping Identity 的企业客户,您可以配置 SAML 联合,以便用户使用其公司凭据登录。 Authentik 将 SAML 断言转换为 OIDC 令牌,因此您的应用程序代码不需要直接处理 SAML。

如何在本地测试身份验证流程?

使用 Docker Compose 在本地与您的应用程序一起运行 Authentik。配置重定向 URI 以包含 http://localhost:3000/auth/callback。对本地用户使用 Authentik 的测试模式。对于交换代码流来说,60秒的TTL对于本地开发来说已经足够了。如果您需要调试 OIDC 流程,Authentik 的管理面板会在事件日志中显示所有令牌交换尝试。


后续步骤

对于任何应用程序来说,实施安全、可投入生产的身份验证系统都是最关键的工程挑战之一。 ECOSIRE 在生产中运行 Authentik,为多个应用程序提供 SSO 服务,具有基于 HttpOnly cookie 的身份验证、一次性交换代码以及 NestJS 和 Next.js 之间的完整 OIDC 集成。

无论您需要 auth 系统架构、Authentik 部署还是完整的企业平台,探索我们的开发服务 以了解我们如何提供帮助。

E

作者

ECOSIRE Research and Development Team

在 ECOSIRE 构建企业级数字产品。分享关于 Odoo 集成、电商自动化和 AI 驱动商业解决方案的洞见。

通过 WhatsApp 聊天