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 Research and Development Team
在 ECOSIRE 构建企业级数字产品。分享关于 Odoo 集成、电商自动化和 AI 驱动商业解决方案的洞见。
相关文章
JWT Authentication: Security Best Practices in 2026
Secure your APIs with JWT best practices: RS256 vs HS256, HttpOnly cookies, token rotation, refresh patterns, and common vulnerabilities to avoid in 2026.
现代应用程序的 API 网关模式和最佳实践
实施 API 网关模式,包括速率限制、身份验证、请求路由、断路器和 API 版本控制,以实现可扩展的 Web 架构。
零信任架构实施:企业实用指南
通过涵盖身份验证、网络分段、设备信任和持续监控的实际步骤来实施零信任架构。