Files
seclusion/apps/api/src/oidc/adapters/redis.adapter.ts
charilezhou 90513e8278 feat: 实现完整的 OIDC Provider 功能
- 后端:基于 node-oidc-provider 实现 OIDC Provider
  - 支持 authorization_code、refresh_token、client_credentials 授权类型
  - Redis adapter 存储会话数据,Prisma adapter 存储持久化数据
  - 客户端管理 CRUD API(创建、更新、删除、重新生成密钥)
  - 交互 API(登录、授权确认、中止)
  - 第一方应用自动跳过授权确认页面
  - 使用 cuid2 生成客户端 ID

- 前端:OIDC 客户端管理界面
  - 客户端列表表格(支持分页、排序)
  - 创建/编辑弹窗(支持所有 OIDC 配置字段)
  - OIDC 交互页面(登录表单、授权确认表单)

- 共享类型:添加 OIDC 相关 TypeScript 类型定义

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 17:22:32 +08:00

129 lines
3.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Adapter, AdapterPayload } from 'oidc-provider';
import { RedisService } from '@/common/redis/redis.service';
// Redis Key 前缀
const KEY_PREFIX = 'oidc';
// 各模型的默认 TTL
const MODEL_TTL: Record<string, number> = {
AuthorizationCode: 600, // 10 分钟
AccessToken: 3600, // 1 小时
Session: 14 * 24 * 3600, // 14 天
Interaction: 3600, // 1 小时
DeviceCode: 600, // 10 分钟
BackchannelAuthenticationRequest: 600, // 10 分钟
};
/**
* Redis 适配器
* 用于存储短期/临时数据AuthorizationCode、AccessToken、Session、Interaction
*/
export class RedisAdapter implements Adapter {
private keyPrefix: string;
constructor(
private redis: RedisService,
private model: string
) {
this.keyPrefix = `${KEY_PREFIX}:${model.toLowerCase()}`;
}
private key(id: string): string {
return `${this.keyPrefix}:${id}`;
}
private grantKey(grantId: string): string {
return `${KEY_PREFIX}:grant:${grantId}`;
}
private userCodeKey(userCode: string): string {
return `${KEY_PREFIX}:usercode:${userCode}`;
}
private uidKey(uid: string): string {
return `${KEY_PREFIX}:uid:${uid}`;
}
async upsert(id: string, payload: AdapterPayload, expiresIn: number): Promise<void> {
const key = this.key(id);
const ttl = expiresIn || MODEL_TTL[this.model] || 3600;
// 存储主数据
await this.redis.setJson(key, payload, ttl);
// 如果有 grantId建立索引
if (payload.grantId) {
const grantKey = this.grantKey(payload.grantId);
await this.redis.sadd(grantKey, key);
await this.redis.expire(grantKey, ttl);
}
// 如果有 userCode建立索引
if (payload.userCode) {
const userCodeKey = this.userCodeKey(payload.userCode);
await this.redis.set(userCodeKey, id, ttl);
}
// 如果有 uid建立索引
if (payload.uid) {
const uidKey = this.uidKey(payload.uid);
await this.redis.set(uidKey, id, ttl);
}
}
async find(id: string): Promise<AdapterPayload | undefined> {
const data = await this.redis.getJson<AdapterPayload>(this.key(id));
return data ?? undefined;
}
async findByUserCode(userCode: string): Promise<AdapterPayload | undefined> {
const id = await this.redis.get(this.userCodeKey(userCode));
if (!id) return undefined;
return this.find(id);
}
async findByUid(uid: string): Promise<AdapterPayload | undefined> {
const id = await this.redis.get(this.uidKey(uid));
if (!id) return undefined;
return this.find(id);
}
async consume(id: string): Promise<void> {
const data = await this.find(id);
if (data) {
data.consumed = Math.floor(Date.now() / 1000);
const ttl = await this.redis.ttl(this.key(id));
if (ttl > 0) {
await this.redis.setJson(this.key(id), data, ttl);
}
}
}
async destroy(id: string): Promise<void> {
const data = await this.find(id);
if (data) {
// 清理关联索引
if (data.grantId) {
const grantKey = this.grantKey(data.grantId);
await this.redis.getClient().srem(grantKey, this.key(id));
}
if (data.userCode) {
await this.redis.del(this.userCodeKey(data.userCode));
}
if (data.uid) {
await this.redis.del(this.uidKey(data.uid));
}
}
await this.redis.del(this.key(id));
}
async revokeByGrantId(grantId: string): Promise<void> {
const grantKey = this.grantKey(grantId);
const keys = await this.redis.smembers(grantKey);
if (keys.length > 0) {
await this.redis.del(...keys, grantKey);
}
}
}