@xllentai/sso-server
v0.1.18
Published
Xllent SSO Server SDK for NestJS 应用
Readme
@xllentai/sso-server
Xllent 平台服务端 SSO SDK,为 NestJS 应用提供 Keycloak 单点登录集成能力。
✨ 特性
- 🚀 开箱即用 - NestJS 依赖注入集成,约 60 行配置
- 🔐 多存储方案 - 支持 JWT / Redis / Database 三种 Session 存储
- 🔒 安全可靠 - JWT 签名验证、Cookie 安全配置、Session 吊销
- 🎯 TypeScript 优先 - 完整的类型定义,智能提示友好
- ⚡ 灵活扩展 - 接口抽象,支持自定义存储实现
- 🧪 测试完备 - 三种存储方案单元测试覆盖
📦 安装
# npm
npm install @xllentai/sso-server
# pnpm
pnpm add @xllentai/sso-server
# yarn
yarn add @xllentai/sso-serverPeer Dependencies
# NestJS 核心依赖
npm install @nestjs/common @nestjs/core @nestjs/jwt
# 可选依赖(根据需要安装)
npm install ioredis # 使用 Redis 存储时
npm install @prisma/client # 使用 Database 存储时🚀 快速开始
1. 配置环境变量
# .env
USER_SERVICE_BASE_URL=http://localhost:3000
JWT_SECRET=your-super-secret-key-at-least-32-characters
NODE_ENV=development2. 在 NestJS Module 中注册
// auth/auth.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { XllentSSOServer } from '@xllentai/sso-server';
@Module({
imports: [ConfigModule],
providers: [
{
provide: 'SSO_SERVER',
useFactory: (configService: ConfigService) => {
return new XllentSSOServer({
userServiceUrl: configService.get<string>('USER_SERVICE_BASE_URL')!,
jwtSecret: configService.get<string>('JWT_SECRET')!,
session: {
issuer: 'xllent',
audience: 'xllent-portal',
expiresInSeconds: 7 * 24 * 60 * 60, // 7天
},
sessionStore: { type: 'jwt' }, // 开发环境使用JWT
});
},
inject: [ConfigService],
},
],
exports: ['SSO_SERVER'],
})
export class AuthModule {}3. 在 Controller 中使用
// auth/auth.controller.ts
import { Controller, Get, Post, Inject, Query, Req, Res, UnauthorizedException } from '@nestjs/common';
import { Request, Response } from 'express';
import { XllentSSOServer } from '@xllentai/sso-server';
@Controller('auth')
export class AuthController {
constructor(
@Inject('SSO_SERVER')
private readonly ssoServer: XllentSSOServer,
) {}
@Get('callback')
async handleCallback(
@Query('temp_token') tempToken: string,
@Res({ passthrough: true }) res: Response,
) {
const session = await this.ssoServer.createSessionFromTempToken(tempToken);
res.cookie(session.cookieOptions.name, session.sessionJwt, {
...session.cookieOptions,
httpOnly: true,
});
if (session.refreshToken) {
res.cookie('x-refresh-token', session.refreshToken, {
httpOnly: true,
secure: session.cookieOptions.secure,
sameSite: 'strict',
path: '/',
maxAge: 30 * 24 * 60 * 60 * 1000,
});
}
return res.redirect('https://portal.example.com');
}
@Get('me')
async getCurrentUser(@Req() req: Request) {
const sessionJwt = req.cookies['x-session-id'];
if (!sessionJwt) {
throw new UnauthorizedException('未登录');
}
const session = await this.ssoServer.verifySession(sessionJwt);
return {
id: session.claims.sub,
username: session.claims.username,
email: session.claims.email,
};
}
@Post('logout')
async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
const sessionJwt = req.cookies['x-session-id'];
if (sessionJwt) {
await this.ssoServer.revokeSession(sessionJwt);
}
// 清除 Cookie
res.clearCookie('x-session-id');
return { success: true };
}
}📖 核心功能
Session 创建
createSessionFromTempToken(tempToken, options?): Promise<CreateSessionResult>
兑换临时 Token,创建平台 Session。
const session = await ssoServer.createSessionFromTempToken(
'temp-abc123',
{ traceId: 'request-uuid' } // 可选:链路追踪ID
);
console.log('Session JWT:', session.sessionJwt);
console.log('Session ID:', session.sessionId);
console.log('用户信息:', session.userInfo);
console.log('租户ID:', session.tenantId);
console.log('Cookie配置:', session.cookieOptions);返回值 CreateSessionResult:
interface CreateSessionResult {
sessionJwt: string; // Session JWT(存储到Cookie)
sessionId: string; // Session ID (jti)
cookieOptions: SessionCookieOptions; // Cookie配置(直接传给 res.cookie)
tenantId: string; // 租户ID
userInfo: { // 用户信息
id: string;
username: string;
email: string;
displayName: string;
avatar?: string;
authUserId: string; // Keycloak用户ID
};
accessToken: string; // User-Service返回的accessToken
refreshToken: string; // User-Service返回的refreshToken
claims: SessionClaims; // JWT载荷
}Session 验证
verifySession(sessionJwt): Promise<VerifySessionResult>
验证 Session JWT,返回载荷信息。
try {
const session = await ssoServer.verifySession(sessionJwt);
console.log('Session ID:', session.sessionId);
console.log('用户ID:', session.claims.sub);
console.log('租户ID:', session.claims.tenantId);
console.log('过期时间:', new Date(session.expiresAt));
} catch (error) {
console.error('Session无效:', error.message);
// Session 无效或已过期
}返回值 VerifySessionResult:
interface VerifySessionResult {
claims: SessionClaims; // JWT载荷
expiresAt: number; // 过期时间戳(毫秒)
sessionId: string; // Session ID (jti)
}
interface SessionClaims {
sub: string; // 用户ID
jti: string; // Session ID
tenantId: string; // 租户ID
authUserId: string; // Keycloak用户ID
username: string; // 用户名
email?: string; // 邮箱
displayName?: string; // 显示名称
sessionVersion: string; // Session版本
type: 'session'; // 类型标识
traceId?: string; // 链路追踪ID
iat: number; // 签发时间
exp: number; // 过期时间
iss: string; // 签发者
aud?: string; // 受众
}Session 吊销
revokeSession(sessionJwt): Promise<void>
吊销 Session(仅 Redis / Database 模式有效)。
await ssoServer.revokeSession(sessionJwt);
console.log('Session已吊销');注意:
- ⚠️ JWT 模式无法主动吊销(无状态设计)
- ✅ Redis / Database 模式支持立即吊销
- 生产环境推荐使用 Redis 或 Database 模式
⚙️ 配置选项
XllentSSOServerOptions
interface XllentSSOServerOptions {
/** User-Service 服务根地址(必填) */
userServiceUrl: string;
/** JWT 签名密钥(必填,建议32字符以上) */
jwtSecret: string;
/** Axios 配置(可选) */
axiosConfig?: {
timeout?: number; // 超时时间(默认8000ms)
headers?: Record<string, string>;
};
/** 接口路径配置(可选) */
endpoints?: {
verifyTempToken?: string; // 默认: /oauth/verify-temp-token
};
/** Session 配置(可选) */
session?: {
issuer?: string; // JWT签发者(默认: xllent)
audience?: string; // JWT受众(可选)
expiresInSeconds?: number; // 过期时间(默认: 7天)
cookie?: Partial<SessionCookieConfig>;
};
/** Session 存储方案(可选,默认JWT) */
sessionStore?: SessionStoreOption;
}Session 存储方案选择
方案1: JWT 模式(开发/测试环境)
特点:
- ✅ 无需额外存储依赖
- ✅ 部署简单,无状态
- ❌ 无法主动吊销 Session
- ❌ 用户强制下线需等待过期
配置:
{
sessionStore: { type: 'jwt' }
}适用场景:
- 开发环境快速验证
- 小型项目(不需要强制登出)
- 短期效 Session(<1小时)
方案2: Redis 模式(生产环境推荐)
特点:
- ✅ 支持 Session 主动吊销
- ✅ 高性能(内存存储)
- ✅ 支持分布式部署
- ✅ 自动过期清理
配置:
import Redis from 'ioredis';
const redis = new Redis({
host: 'localhost',
port: 6379,
});
{
sessionStore: {
type: 'redis',
redis, // 实现 RedisLikeClient 接口即可
keyPrefix: 'sso:session:' // 可选,默认 'sso:session:'
}
}RedisLikeClient 接口:
interface RedisLikeClient {
set(key: string, value: string, options: { PX: number }): Promise<void>;
get(key: string): Promise<string | null>;
del(key: string): Promise<number>;
}适用场景:
- 生产环境(推荐)
- 需要强制下线功能
- 分布式部署
- 高并发场景
方案3: Database 模式(持久化需求)
特点:
- ✅ 支持 Session 主动吊销
- ✅ 数据持久化(审计需求)
- ✅ 支持复杂查询(如:查询某用户所有Session)
- ⚠️ 性能略低于 Redis
配置:
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const repository: DatabaseSessionRepository = {
async save(record) {
await prisma.session.create({
data: {
jti: record.claims.jti!,
sessionJwt: record.sessionJwt,
userId: record.claims.sub,
tenantId: record.claims.tenantId,
expiresAt: new Date(record.expiresAt),
},
});
},
async exists(jti) {
const session = await prisma.session.findUnique({
where: { jti, expiresAt: { gt: new Date() } },
});
return !!session;
},
async delete(jti) {
await prisma.session.delete({ where: { jti } });
},
};
{
sessionStore: {
type: 'database',
repository
}
}DatabaseSessionRepository 接口:
interface DatabaseSessionRepository {
save(record: SessionRecord): Promise<void>;
exists(jti: string): Promise<boolean>;
delete(jti: string): Promise<void>;
}
interface SessionRecord {
sessionJwt: string;
claims: SessionClaims;
expiresAt: number;
}适用场景:
- 审计日志需求
- 需要查询历史 Session
- 数据持久化要求
- 不想引入 Redis 依赖
Cookie 配置
interface SessionCookieConfig {
name: string; // Cookie名称(默认: x-session-id)
httpOnly: boolean; // 仅HTTP访问(默认: true)
secure: boolean; // 仅HTTPS传输(默认: true)
sameSite: 'lax' | 'strict' | 'none'; // CSRF防护(默认: lax)
path: string; // Cookie路径(默认: /)
domain?: string; // Cookie域名(可选)
}生产环境推荐配置:
{
session: {
cookie: {
name: 'x-session-id',
httpOnly: true, // ✅ 防止JavaScript读取
secure: true, // ✅ 仅HTTPS传输
sameSite: 'strict', // ✅ 严格CSRF防护
path: '/',
domain: '.example.com' // ✅ 支持子域名共享
}
}
}🔒 安全最佳实践
⚠️ JWT 模式的安全局限性
问题: JWT 模式无法主动吊销 Session,存在安全风险。
风险场景:
// 用户点击"登出"
await ssoServer.revokeSession(sessionJwt); // ❌ JWT模式无效
// 但攻击者仍可使用旧的JWT访问API(直到过期)解决方案:
生产环境使用 Redis / Database 模式
{ sessionStore: { type: 'redis', redis: new Redis(/* ... */), } }缩短 JWT 有效期
{ session: { expiresInSeconds: 15 * 60, // 15分钟 } }添加环境检查
const isProduction = process.env.NODE_ENV === 'production'; if (isProduction && storeConfig.type === 'jwt') { throw new Error('生产环境不允许使用JWT模式,请切换到Redis或Database'); }
🛡️ JWT 密钥强度
问题: 弱密钥容易被破解。
推荐:
// ❌ 不安全
jwtSecret: '123456'
// ❌ 不安全(太短)
jwtSecret: 'my-secret'
// ✅ 推荐(32字符以上,随机生成)
jwtSecret: crypto.randomBytes(32).toString('hex')
// 示例: a3f9c8b2e1d4f7a6c5b8e3d2f1a9b7c4e6d8f2a1b3c5e7d9f1a2b4c6e8d0f2a4验证密钥强度:
if (jwtSecret.length < 32) {
throw new Error('JWT密钥长度至少32字符');
}🛡️ 防止信息泄露
问题: 错误消息可能泄露系统信息。
不推荐:
throw new Error('Session 缺少 jti,无法验证 Redis 存储');推荐:
// 生产环境返回通用错误
const message = isProduction
? 'Session 无效'
: 'Session 缺少 jti,无法验证 Redis 存储';
throw new Error(message);🛡️ CORS 配置
跨域请求需要正确配置:
// main.ts
app.enableCors({
origin: ['https://app.example.com'],
credentials: true, // ✅ 允许携带Cookie
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
});📚 完整示例
NestJS 完整集成(生产级)
// config/configuration.ts
export default () => ({
env: {
isProduction: process.env.NODE_ENV === 'production',
userService: {
baseUrl: process.env.USER_SERVICE_BASE_URL,
},
jwt: {
secret: process.env.JWT_SECRET,
},
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
},
},
});
// auth/auth.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { XllentSSOServer } from '@xllentai/sso-server';
import Redis from 'ioredis';
@Module({
imports: [ConfigModule],
providers: [
{
provide: 'REDIS_CLIENT',
useFactory: (configService: ConfigService) => {
return new Redis({
host: configService.get<string>('env.redis.host'),
port: configService.get<number>('env.redis.port'),
});
},
inject: [ConfigService],
},
{
provide: 'SSO_SERVER',
useFactory: (configService: ConfigService, redis: Redis) => {
const isProduction = configService.get<boolean>('env.isProduction');
return new XllentSSOServer({
userServiceUrl: configService.get<string>('env.userService.baseUrl')!,
jwtSecret: configService.get<string>('env.jwt.secret')!,
session: {
issuer: 'xllent',
audience: 'xllent-portal',
expiresInSeconds: 7 * 24 * 60 * 60,
cookie: {
name: 'x-session-id',
httpOnly: true,
secure: isProduction,
sameSite: isProduction ? 'strict' : 'lax',
path: '/',
domain: isProduction ? '.example.com' : undefined,
},
},
sessionStore: {
type: 'redis',
redis,
keyPrefix: 'sso:session:',
},
});
},
inject: [ConfigService, 'REDIS_CLIENT'],
},
],
exports: ['SSO_SERVER'],
})
export class AuthModule {}
// auth/auth.guard.ts
import { Injectable, CanActivate, ExecutionContext, Inject } from '@nestjs/common';
import { XllentSSOServer } from '@xllentai/sso-server';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
@Inject('SSO_SERVER')
private readonly ssoServer: XllentSSOServer,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const sessionJwt = request.cookies['x-session-id'];
if (!sessionJwt) {
return false;
}
try {
const session = await this.ssoServer.verifySession(sessionJwt);
request.user = session.claims; // 注入用户信息到请求
return true;
} catch {
return false;
}
}
}
// user/user.controller.ts
import { Controller, Get, UseGuards, Req } from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard';
@Controller('user')
@UseGuards(AuthGuard)
export class UserController {
@Get('profile')
getProfile(@Req() req) {
return {
id: req.user.sub,
username: req.user.username,
email: req.user.email,
tenantId: req.user.tenantId,
};
}
}❓ 常见问题
Q: 如何选择 Session 存储方案?
| 场景 | 推荐方案 | 原因 | |------|---------|------| | 开发环境 | JWT | 无需额外依赖,快速验证 | | 小型项目(<1000用户) | JWT | 部署简单,成本低 | | 生产环境 | Redis | 高性能 + 支持吊销 | | 审计需求 | Database | 数据持久化,可查询历史 | | 分布式部署 | Redis | 支持多实例共享Session |
Q: 如何实现"强制下线"功能?
// 1. 使用 Redis / Database 模式
{
sessionStore: {
type: 'redis',
redis: redisClient,
}
}
// 2. 在管理端提供接口
@Post('admin/force-logout')
async forceLogout(@Body('userId') userId: string) {
// 查询该用户的所有Session(需自行维护userId->sessionId映射)
const sessionIds = await this.getSessionsByUserId(userId);
// 批量吊销
for (const sessionId of sessionIds) {
await redis.del(`sso:session:${sessionId}`);
}
return { success: true };
}Q: 如何实现"单点登出"(其他应用同步下线)?
// 1. 使用共享Redis
const sharedRedis = new Redis({ /* 所有应用共享 */ });
// 2. 登出时发布消息
@Post('logout')
async logout(@Req() req, @Res() res) {
const sessionJwt = req.cookies['x-session-id'];
if (sessionJwt) {
await this.ssoServer.revokeSession(sessionJwt);
// 发布登出事件
await redis.publish('sso:logout', JSON.stringify({
sessionId: session.claims.jti,
userId: session.claims.sub,
}));
}
res.clearCookie('x-session-id');
return { success: true };
}
// 3. 订阅登出事件
redis.subscribe('sso:logout');
redis.on('message', (channel, message) => {
const { sessionId } = JSON.parse(message);
// 清理本地缓存(如有)
});Q: 如何处理 Cookie 跨域问题?
// 1. 后端设置 CORS
app.enableCors({
origin: 'https://app.example.com',
credentials: true, // ✅ 必须设置
});
// 2. 前端请求携带凭证
fetch('https://api.example.com/auth/me', {
credentials: 'include', // ✅ 必须设置
});
// 3. Cookie 配置域名
{
session: {
cookie: {
domain: '.example.com', // ✅ 支持子域名共享
sameSite: 'none', // ✅ 跨域必须设置为none
secure: true, // ✅ sameSite=none时必须secure
}
}
}Q: 如何在单元测试中 Mock?
import { Test } from '@nestjs/testing';
import { XllentSSOServer } from '@xllentai/sso-server';
const mockSSOServer = {
createSessionFromTempToken: jest.fn(),
verifySession: jest.fn(),
revokeSession: jest.fn(),
};
const module = await Test.createTestingModule({
controllers: [AuthController],
providers: [
{
provide: 'SSO_SERVER',
useValue: mockSSOServer,
},
],
}).compile();
// 测试
mockSSOServer.verifySession.mockResolvedValue({
claims: { sub: 'user-1', username: 'test' },
sessionId: 'session-1',
expiresAt: Date.now() + 3600000,
});🔗 相关链接
📄 许可证
MIT License
维护团队: Xllent Platform Team 问题反馈: GitHub Issues
