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/) 中:
- 转到 应用程序 > 提供程序 > 创建
- 选择 OAuth2/OpenID 提供商
- 配置:
- 名称:
ECOSIRE Web OAuth2 - 授权流程:
default-provider-authorization-implicit-consent - 客户端类型:
Confidential - 客户端 ID:
ecosire-web(您选择此) - 重定向 URI:
https://api.ecosire.com/api/auth/callback https://ecosire.com/auth/callback - 签名密钥:选择您的默认证书
- 范围:
openid、email、profile
- 创建一个使用此提供程序的应用程序
自定义属性映射
要在 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';
}
}
Cookie 配置
// 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 部署还是完整的企业平台,探索我们的开发服务 以了解我们如何提供帮助。
作者
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
相关文章
API 安全 2026:身份验证和授权最佳实践(与 OWASP 一致)
符合 OWASP 的 2026 API 安全指南:OAuth 2.1、PASETO/JWT、密钥、RBAC/ABAC/OPA、速率限制、机密管理、审核日志记录和十大错误。
JWT 身份验证:2026 年安全最佳实践
使用 JWT 最佳实践保护您的 API:RS256 与 HS256、HttpOnly cookie、令牌轮换、刷新模式以及 2026 年要避免的常见漏洞。
现代应用程序的 API 网关模式和最佳实践
实施 API 网关模式,包括速率限制、身份验证、请求路由、断路器和 API 版本控制,以实现可扩展的 Web 架构。