[RFC] 107 - OAuth 2.0 / OIDC 授权 #7377
Replies: 4 comments 1 reply
-
DB Schema/* eslint-disable sort-keys-fix/sort-keys-fix */
import { boolean, jsonb, pgTable, primaryKey, text, varchar } from 'drizzle-orm/pg-core';
import { timestamps, timestamptz } from './_helpers';
import { users } from './user';
/**
* OIDC 授权码
* oidc-provider 需要持久化的模型之一
*/
export const oidcAuthorizationCodes = pgTable('oidc_authorization_codes', {
id: varchar('id', { length: 255 }).primaryKey(),
data: jsonb('data').notNull(),
expiresAt: timestamptz('expires_at').notNull(),
consumedAt: timestamptz('consumed_at'),
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
clientId: varchar('client_id', { length: 255 }).notNull(),
grantId: varchar('grant_id', { length: 255 }),
...timestamps,
});
/**
* OIDC 访问令牌
* oidc-provider 需要持久化的模型之一
*/
export const oidcAccessTokens = pgTable('oidc_access_tokens', {
id: varchar('id', { length: 255 }).primaryKey(),
data: jsonb('data').notNull(),
expiresAt: timestamptz('expires_at').notNull(),
consumedAt: timestamptz('consumed_at'),
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
clientId: varchar('client_id', { length: 255 }).notNull(),
grantId: varchar('grant_id', { length: 255 }),
...timestamps,
});
/**
* OIDC 刷新令牌
* oidc-provider 需要持久化的模型之一
*/
export const oidcRefreshTokens = pgTable('oidc_refresh_tokens', {
id: varchar('id', { length: 255 }).primaryKey(),
data: jsonb('data').notNull(),
expiresAt: timestamptz('expires_at').notNull(),
consumedAt: timestamptz('consumed_at'),
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
clientId: varchar('client_id', { length: 255 }).notNull(),
grantId: varchar('grant_id', { length: 255 }),
...timestamps,
});
/**
* OIDC 设备代码
* oidc-provider 需要持久化的模型之一
*/
export const oidcDeviceCodes = pgTable('oidc_device_codes', {
id: varchar('id', { length: 255 }).primaryKey(),
data: jsonb('data').notNull(),
expiresAt: timestamptz('expires_at').notNull(),
consumedAt: timestamptz('consumed_at'),
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }),
clientId: varchar('client_id', { length: 255 }).notNull(),
grantId: varchar('grant_id', { length: 255 }),
userCode: varchar('user_code', { length: 255 }),
...timestamps,
});
/**
* OIDC 交互会话
* oidc-provider 需要持久化的模型之一
*/
export const oidcInteractions = pgTable('oidc_interactions', {
id: varchar('id', { length: 255 }).primaryKey(),
data: jsonb('data').notNull(),
expiresAt: timestamptz('expires_at').notNull(),
...timestamps,
});
/**
* OIDC 授权记录
* oidc-provider 需要持久化的模型之一
*/
export const oidcGrants = pgTable('oidc_grants', {
id: varchar('id', { length: 255 }).primaryKey(),
data: jsonb('data').notNull(),
expiresAt: timestamptz('expires_at').notNull(),
consumedAt: timestamptz('consumed_at'),
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
clientId: varchar('client_id', { length: 255 }).notNull(),
...timestamps,
});
/**
* OIDC 客户端配置
* 存储 OIDC 客户端配置信息
*/
export const oidcClients = pgTable('oidc_clients', {
id: varchar('id', { length: 255 }).primaryKey(), // client_id
name: text('name').notNull(),
description: text('description'),
clientSecret: varchar('client_secret', { length: 255 }), // 公共客户端可为 null
redirectUris: text('redirect_uris').array().notNull(),
grants: text('grants').array().notNull(),
responseTypes: text('response_types').array().notNull(),
scopes: text('scopes').array().notNull(),
tokenEndpointAuthMethod: varchar('token_endpoint_auth_method', { length: 20 }),
applicationType: varchar('application_type', { length: 20 }),
clientUri: text('client_uri'),
logoUri: text('logo_uri'),
policyUri: text('policy_uri'),
tosUri: text('tos_uri'),
isFirstParty: boolean('is_first_party').default(false),
...timestamps,
});
/**
* OIDC 会话
* oidc-provider 需要持久化的模型之一
*/
export const oidcSessions = pgTable('oidc_sessions', {
id: varchar('id', { length: 255 }).primaryKey(),
data: jsonb('data').notNull(),
expiresAt: timestamptz('expires_at').notNull(),
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
...timestamps,
});
/**
* OIDC 授权同意记录
* 记录用户对客户端的授权同意历史
*/
export const oidcConsents = pgTable(
'oidc_consents',
{
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
clientId: varchar('client_id', { length: 255 })
.references(() => oidcClients.id, { onDelete: 'cascade' })
.notNull(),
scopes: text('scopes').array().notNull(),
expiresAt: timestamptz('expires_at'),
...timestamps,
},
(table) => ({
pk: primaryKey({ columns: [table.userId, table.clientId] }),
}),
); |
Beta Was this translation helpful? Give feedback.
-
OAuth 服务器实现对比:node-oauth/oauth2-server vs oidc-provider本文档比较了 LobeChat 中两种 OAuth 服务器实现方式的异同点,帮助开发者理解各自的优缺点。 1. 实现架构对比使用 @node-oauth/oauth2-server (原实现)核心架构:
适配层: // 将 Next Request 转为 Express Request
convertNextRequestToExpressRequest(req: NextRequest): ExpressRequest
// 创建空的 Express Response
createExpressResponse(): ExpressResponse
// 将处理后的 Express Response 转回 Next Response
convertExpressResponseToNextResponse(res: ExpressResponse): NextResponse 使用 oidc-provider (新实现)核心架构:
适配层: // 将 Next Request 转为 Node IncomingMessage
convertNextRequestToNodeRequest(req: NextRequest, url: string): IncomingMessage
// 处理 OIDC Provider 请求,并返回 Next Response
handleOIDCRequest(provider: Provider, req: NextRequest, mountPath: string): Promise<NextResponse> 2. 功能完整性对比
3. 代码量对比
4. 与 Next.js 集成难度
5. 安全性对比
6. 性能与可扩展性
7. 文档与社区支持
8. 总结@node-oauth/oauth2-server 优势
@node-oauth/oauth2-server 劣势
oidc-provider 优势
oidc-provider 劣势
9. 推荐选择针对 LobeChat 项目,oidc-provider 是更为现代、安全、功能完整的选择。尽管它需要一些初始学习成本,但长期来看能够提供更好的安全性、标准合规性和可维护性。对于需要完整 OAuth 2.0/OpenID Connect 支持的产品级应用,oidc-provider 是更合适的选择。 |
Beta Was this translation helpful? Give feedback.
-
测试验证方法进行全面细致的测试验证至关重要,以确保其功能正确、安全可靠。 以下是一些建议的测试验证步骤和方面,你可以根据你的具体实现情况进行调整: 一、 核心流程 (E2E) 测试 (Authorization Code Grant with PKCE) 这是最关键的流程,需要优先验证。你可以使用 Postman、cURL 或专门的 OIDC 客户端库(如 Node.js 的
二、 Refresh Token 流程测试
三、 其他端点和功能测试
四、 单元/集成测试 除了 E2E 测试,针对关键逻辑单元编写测试也很重要:
五、 安全性检查
建议的下一步:
祝你测试顺利!这部分工作虽然繁琐,但对确保 OIDC 服务的健壮性和安全性至关重要。 |
Beta Was this translation helpful? Give feedback.
-
一个深坑:oidc-provider 依赖 ModelConstructor.name 来获取要传递给 Adapter 工厂的字符串 源码分析 ( // Abridged from lib/models/base_model.js
const adapterCache = new WeakMap();
export default function getBaseModel(provider) {
function adapter(ctx) { // ctx here is either the model class or a model instance
const obj = typeof ctx === 'function' ? ctx : ctx.constructor; // Get the constructor
if (!adapterCache.has(obj)) {
if (isConstructable(instance(provider).Adapter)) {
// If adapter is a constructor
adapterCache.set(obj, new (instance(provider).Adapter)(obj.name)); // <-- Uses obj.name
} else {
// If adapter is a factory function (your case)
adapterCache.set(obj, instance(provider).Adapter(obj.name)); // <-- Uses obj.name
}
}
return adapterCache.get(obj);
}
class Class {
// ... constructor ...
// Example: save method calls this.adapter
async save(ttl) {
// ... other logic ...
await this.adapter.upsert(this.jti, payload, ttl); // <-- Calls the adapter getter
// ... other logic ...
}
// Adapter getter on the instance
get adapter() {
return adapter(this); // <-- Calls the adapter function with the instance
}
// Adapter getter on the class
static get adapter() {
return adapter(this); // <-- Calls the adapter function with the class
}
// ... other methods ...
}
// ... (BaseModel definition) ...
return BaseModel;
} 关键点:
日志与源码结合分析:
结论 (再次指向构建过程): 结合源码和日志,最符合逻辑的解释仍然是 Vercel 的构建过程(SWC 压缩/混淆)修改或移除了类的 当 解决方案:#7430 |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
1. 摘要 (Abstract)
本 RFC 提议在 LobeChat Web 服务端引入基于
oidc-provider
库的 OAuth 2.0 / OIDC (OpenID Connect) 授权服务器。其主要目的是为 LobeChat 的第一方客户端(如桌面应用、未来可能的移动应用等)提供一个安全、标准的授权机制,以访问用户数据和后端 API(例如用于数据同步)。此服务将与现有的用户认证体系(如 Clerk, NextAuth.js)紧密集成,利用已登录的用户会话来处理授权请求。该实现将遵循行业最佳实践,强制使用 PKCE,支持 Refresh Token,并为未来构建 LobeChat OpenAPI 生态系统奠定认证与授权基础。2. 动机 (Motivation)
随着 LobeChat 生态扩展到桌面端等多种形态,需要一个比简单 Session 或 API Key 更健壮、更安全的机制来允许这些客户端代表用户访问其数据。直接在客户端处理用户主凭证(如密码)或长期有效的 Token 存在显著安全风险。引入 OAuth 2.0 / OIDC 授权服务器可以解决这些问题:
oidc-provider
是一个功能完善、社区活跃的 Node.js OIDC 库,能大幅简化开发和维护工作,并确保协议实现的准确性和安全性。3. 提案 (Proposal) - Web 服务端实现细节
3.1. 技术选型
oidc-provider
(Node.js OIDC/OAuth2 Server Library)oidc-provider
Adapter,用于将 OIDC 状态(Codes, Tokens, Sessions 等)持久化到 LobeChat 使用的数据库中( PostgreSQL via Drizzle)。3.2.
oidc-provider
核心配置在 LobeChat 后端初始化时配置
oidc-provider
实例:3.3. 数据库适配器 (Adapter) 实现
oidc-provider
运行所需的各种模型数据(如Session
,AccessToken
,AuthorizationCode
,RefreshToken
,Client
,DeviceCode
等)。oidc-provider
适配器(如oidc-provider-adapter-node-postgres
,oidc-provider-adapter-redis
),优先考虑使用并进行配置。oidc-provider
的 Adapter 接口规范,自行编写适配器逻辑,将 OIDC 模型映射到 LobeChat 的数据库模式中。这需要一定工作量。3.4. 与现有认证体系 (Clerk/NextAuth) 的集成
通过
interactions.policy
配置实现:/interaction/:uid
) 来处理login
和consent
提示。login
提示 (即check_existing_session
返回REQUEST_PROMPT
),此页面应重定向到 LobeChat 现有的 Clerk/NextAuth 登录页面。登录成功后,Clerk/NextAuth 需要重定向回 OIDC 的交互 URL (/interaction/:uid
) 并附带成功状态。oidc-provider
获取交互详情 (provider.interactionDetails(req, res)
),展示请求授权的客户端信息 (details.params.client_id
) 和请求的权限范围 (details.params.scope
)。用户点击同意后,调用provider.interactionFinished(req, res, result, { mergeWithLastSubmission: false })
完成授权。3.5. API 端点
oidc-provider
会自动处理以下标准端点(挂载在配置的基础路径下,如/oidc
):/auth
: 授权端点 (GET)/token
: 令牌端点 (POST)/jwks
: JWK Set 端点 (GET)/userinfo
: 用户信息端点 (GET/POST)/revocation
: 令牌撤销端点 (POST)/introspection
: 令牌自省端点 (POST)/device_authorization
: 设备授权端点 (POST)3.6. 安全性考量
jwks
和cookies.keys
。oidc-provider
内部会做很多验证,但与外部系统(如 Clerk/NextAuth)交互的部分需要确保数据传递和状态转换的正确性。oidc-provider
及其依赖,以获取安全补丁。4. 影响 (Impacts)
oidc-provider
的精细配置以及与现有认证体系的交互逻辑开发 (交互页面和interactions.policy
、findAccount
钩子)。5. 未解决的问题 (Open Questions)
oidc-provider
配置调优(例如,各种 TTL 的最佳值,是否启用所有推荐的 feature)。6. 未来工作 (Future Work)
client_credentials
Grant Type,用于支持 M2M 场景或 OpenAPI Key 的管理。进展
Beta Was this translation helpful? Give feedback.
All reactions