@ codefox- inc/ oauth- 提供者
OAuth 2.1 / OpenID 连接提供器作为Convex组件被执行.
QQ Beta 软件 - 生产用在你自己的风险.
测试过 凸起轴 财务报告和审定财务报表 @ convex-dev/ better-auth (英语)。 。 。 .
为什么?
大多数MCP客户端(如克洛德代码或ChatGPT)都要求您的应用程序成为OAuth供应商. 如果您想要将您的 Convex 应用程序连接到 MCP 客户端, 您需要执行 OAuth 2.1.
此组件将您的 Convex 应用程序变成一个完全符合 OAuth 2.1 的提供者,所以您可以:
- 从框中连接 MCP 客户端
- 让客户端通过动态客户端注册自动注册
- 让用户控制每个应用程序的权限
- 专注于您的应用程序, 而非 OAuth 复杂度
安装
bun add @codefox-inc/oauth-provider
特征
- OAuth 2.1 符合规定 授权和符号终点
- OpenID 连接发现 用于自动客户端配置
- 需要PKCE 所有授权代码流量(仅限S256)
- RFC 8707 资源指标 供观众使用
- RFC 9068 JWT 访问令牌 (单位:千美元)
typ: at+jwt, (中文(简体) ),client_id, (中文(简体) ),scope, (中文(简体) ),jti(中文(简体) ) - 安全信使存储 使用SHA-256散列作为代币和授权代码
- JWT 访问符 与 RS256 签名
- 刷新符号旋转 加强安保
- 动态客户注册 (选入)
- 授权管理 用户同意跟踪
- JWKS 结束点 用于符号验证
OAuth 2.1 遵约情况
其后执行 奥奥特2.1 及相关的OAuth/OIDC规格:
支持的赠款类型
- ✅ 带有 PKCE 的授权代码 (公共和保密客户)
- ✅ 刷新托肯 (带有符号旋转)
不支持的地物( OAuth 2. 0 遗产)
- ❌ 间接赠款 (因安全原因在OAuth 2.1被拆除)
- ❌ 资源所有人密码证书赠款 (在OAuth 2.1中删除)
- ❌ PKCE 平原方法 (每个OAuth2.1最佳做法只支持S256)
关键安全要求
- PKCE 执行: 所有授权代码流量需要使用 S256 方法的 PKCE
- 重定向 URI 验证: 精确字符串匹配(仅限RFC 8252回转可变端口例外)
- 资源约束数字 :
resource值与授权授予和刷新令牌绑定 - 访问托肯观众: 访问令牌
aud是授权的resource,或配置的默认受众 - 授权代码: 单用,10分钟后到期
- 托肯·哈兴: 所有以 SHA-256 散列存储的令牌
- 刷新托肯旋转: 每次使用时发布新的刷新令牌,旧令牌作废
安全特性
内设安全控制
- PKCE 执行: 所有授权代码流量都需要PKCE(代码 质疑/代码 验证)
- 重定向 URI 验证: 严格核对已注册的 URI
- 范围验证:每个客户只允许注册范围
- 托肯·哈兴: 访问和刷新令牌作为SHA-256散列存储
- 客户端秘密搜索: 使用 bcrypt 的客户机密
- 客户端机密兼容性:新发布的符合bcrypt72字节输入限制的客户机密;现有的更长的秘密对于补丁放出兼容性仍然有效,在实用时应当被旋转
- 内部变异:关键业务,如
issueAuthorizationCode无法直接访问 - 默认禁用 DCR: 必须明确启用动态客户端注册
授权流量安全
这个 /oauth/authorize 端点进行全面的验证 :
- 客户身份验证
- 根据注册的 URI 重定向 URI 匹配
- 对照客户端允许的范围进行范围验证
- PKCE 要求(使用 S256 方法的代码-挑战)
- 用户认证通过
getUserId钩子
范围和托肯类型
支持的范围
openid: OpenID 连接认证和ID 令牌需要profile: 授权访问用户配置信息(名称、图片)email: 授权访问用户电子邮件地址offline_access: 为长期访问启用刷新令牌发行
刷新托肯要求
刷新符号是 仅印发 当 offline_access 在最初授权期间,请求并授予范围。 对于 OpenID 连接请求, offline_access 需求 prompt=consent (或包含 consent:
- ✅ 与
offline_access:客户端同时收到访问令牌和刷新令牌 - ❌ 无
offline_access:客户端仅接收访问令牌(无刷新令牌)
刷新托肯赠款流程 :
- 使用
refresh_token获取新访问符的赠款类型 - 原始授权必须包括:
offline_access范围 - 刷新令牌在每次使用时自动旋转(旧令牌无效)
- 新的刷新令牌保持与原值相同的范围
- 重用旋转的刷新令牌将活动刷新的家族及其授权记录取消
这遵循了OAuth 2.1和OpenID Connect的规格,确保了长寿的刷新令牌只有在得到用户明确同意的情况下才发布.
资源指标和有约束力受众
这个提供者支持 RFC 8707 resource mCP和其他资源服务器流量的指标.
resource可在授权请求中选择.- 如果存在,它必须是绝对的无碎片URI.
- 授权代码存储批准
resource。 。 。 . - 令牌请求可能重复同样内容
resource,但不能添加未经批准的新的. - 刷新令牌在旋转时保留相同的资源/听众绑定 .
- 访问符使用授权
resource作为aud; 否则使用applicationID默认convex观众.
对于自定义同意的 UI , 保存收到的 resource 参数并传递给 issueAuthorizationCode。 。 。 .
OAuth 托肯检测帮助工具
提供区分 OAuth 令牌和会话令牌的帮助函数 :
import { isOAuthToken, getOAuthClientId } from "@codefox-inc/oauth-provider";
const identity = await ctx.auth.getUserIdentity();
if (isOAuthToken(identity)) {
// Handle OAuth token (MCP client, third-party apps, etc.)
const clientId = getOAuthClientId(identity);
console.log("OAuth client:", clientId);
} else {
// Handle Convex Auth session (first-party user)
}
设置
1. 设置环境变量
这个组件与任何认证系统相配合. 选择匹配您的堆栈的设置 :
备选案文A:带有 Convex Auth
如果你在用的话 凸起轴,您已经配置了所需的环境变量(JWT_PRIVATE_KEY, (中文(简体) ), JWKS, (中文(简体) ), SITE_URL) (中文(简体) ).
备选案文B:使用更好的认证
如果你在用的话 @ convex-dev/ better-auth (英语),您可以共享相同的密钥 :
bunx convex env set OAUTH_PRIVATE_KEY "$(cat private.pem)" # Or use JWT_PRIVATE_KEY
bunx convex env set OAUTH_JWKS '{"keys":[...]}' # Or use JWKS
bunx convex env set SITE_URL "https://your-app.example.com"
重要内容: 当使用更好的 Auth 时, 设置 applicationID: "oauth-provider" 在 OAuth 配置中区分 OAuth 符号和 Better Auth 会话符号.
选项 C: 手工设置
手动生成 RSA 密钥 :
# Generate private key
openssl genrsa -out private.pem 2048
# Generate JWKS (use https://mkjwk.org or this script)
node -e "
const jose = require('jose');
const fs = require('fs');
const privateKey = fs.readFileSync('private.pem', 'utf8');
(async () => {
const key = await jose.importPKCS8(privateKey, 'RS256');
const jwk = await jose.exportJWK(key);
console.log(JSON.stringify({ keys: [{ ...jwk, use: 'sig', alg: 'RS256', kid: 'default-key' }] }));
})();
"
设置环境变量 :
bunx convex env set JWT_PRIVATE_KEY "-----BEGIN RSA PRIVATE KEY-----\n..."
bunx convex env set JWKS '{"keys":[...]}'
bunx convex env set SITE_URL "https://your-app.example.com"
2. 登记部分
// convex/convex.config.ts
import { defineApp } from "convex/server";
import oauthProvider from "@codefox-inc/oauth-provider/convex.config";
const app = defineApp();
app.use(oauthProvider, { name: "oauthProvider" });
export default app;
3. 配置 HTTP 路由
备选案文A:使用帮助器函数(建议)
// convex/http.ts
import { httpAction } from "./_generated/server";
import { httpRouter } from "convex/server";
import { OAuthProvider, registerOAuthRoutes } from "@codefox-inc/oauth-provider";
import { components } from "./_generated/api";
import { api } from "./_generated/api";
const http = httpRouter();
const oauthProvider = new OAuthProvider(components.oauthProvider, {
privateKey: process.env.JWT_PRIVATE_KEY!,
jwks: process.env.JWKS!,
siteUrl: process.env.SITE_URL!,
// REQUIRED: Authenticate user for authorization endpoint
getUserId: async (ctx, request) => {
const identity = await ctx.auth.getUserIdentity();
return identity?.subject ?? null;
},
});
// Register all OAuth routes automatically
registerOAuthRoutes(http, httpAction, oauthProvider, {
siteUrl: process.env.SITE_URL!,
// OPTIONAL: Override the prefix used for route registration.
// By default, this uses oauthProvider's config prefix.
// prefix: "/oauth",
getUserProfile: async (ctx, userId) => {
// Return user profile for /oauth/userinfo endpoint
const user = await ctx.runQuery(api.users.get, { userId });
return user ? {
sub: userId,
name: user.name,
email: user.email,
picture: user.pictureUrl
} : null;
},
});
export default http;
备选案文B:使用更好的认证
// convex/http.ts
import { httpAction } from "./_generated/server";
import { httpRouter } from "convex/server";
import { OAuthProvider, registerOAuthRoutes } from "@codefox-inc/oauth-provider";
import { components } from "./_generated/api";
import { api } from "./_generated/api";
const http = httpRouter();
const oauthProvider = new OAuthProvider(components.oauthProvider, {
privateKey: process.env.OAUTH_PRIVATE_KEY ?? process.env.JWT_PRIVATE_KEY!,
jwks: process.env.OAUTH_JWKS ?? process.env.JWKS!,
siteUrl: process.env.SITE_URL!,
// IMPORTANT: Set applicationID to distinguish from Better Auth tokens
applicationID: "oauth-provider",
getUserId: async (ctx, request) => {
const identity = await ctx.auth.getUserIdentity();
return identity?.subject ?? null;
},
});
// Register Better Auth routes first (if using @convex-dev/better-auth)
// authComponent.registerRoutes(http, createAuth, { cors: true });
// Then register OAuth routes
registerOAuthRoutes(http, httpAction, oauthProvider, {
siteUrl: process.env.SITE_URL!,
getUserProfile: async (ctx, userId) => {
const user = await ctx.runQuery(api.users.get, { userId });
return user ? {
sub: userId,
name: user.name,
email: user.email,
picture: user.pictureUrl
} : null;
},
});
export default http;
备选案文C:人工路由登记
// convex/http.ts
import { httpAction } from "./_generated/server";
import { httpRouter } from "convex/server";
import { OAuthProvider } from "@codefox-inc/oauth-provider";
import { components } from "./_generated/api";
const http = httpRouter();
const oauthProvider = new OAuthProvider(components.oauthProvider, {
privateKey: process.env.JWT_PRIVATE_KEY!,
jwks: process.env.JWKS!,
siteUrl: process.env.SITE_URL!,
// REQUIRED: Authenticate user for authorization endpoint
getUserId: async (ctx, request) => {
const identity = await ctx.auth.getUserIdentity();
return identity?.subject ?? null;
},
});
// OpenID Connect Discovery
http.route({
path: "/oauth/.well-known/openid-configuration",
method: "GET",
handler: httpAction((ctx, req) =>
oauthProvider.handlers.openIdConfiguration(ctx, req)
),
});
// JWKS endpoint
http.route({
path: "/oauth/.well-known/jwks.json",
method: "GET",
handler: httpAction((ctx, req) =>
oauthProvider.handlers.jwks(ctx, req)
),
});
// Authorization endpoint (validates and issues auth codes)
http.route({
path: "/oauth/authorize",
method: "GET",
handler: httpAction((ctx, req) =>
oauthProvider.handlers.authorize(ctx, req)
),
});
// Token endpoint
http.route({
path: "/oauth/token",
method: "POST",
handler: httpAction((ctx, req) =>
oauthProvider.handlers.token(ctx, req)
),
});
// UserInfo endpoint
http.route({
path: "/oauth/userinfo",
method: "GET",
handler: httpAction((ctx, req) =>
oauthProvider.handlers.userInfo(ctx, req, async (userId) => {
const user = await ctx.runQuery(api.users.get, { userId });
return user ? { sub: userId, name: user.name, email: user.email } : null;
})
),
});
// Dynamic Client Registration (optional)
http.route({
path: "/oauth/register",
method: "POST",
handler: httpAction((ctx, req) =>
oauthProvider.handlers.register(ctx, req)
),
});
export default http;
用户信息端点
要求数 openid 范围。 根据范围提出的回返申请:
openid: 总是返回subprofile页:1name, (中文(简体) ),pictureemail页:1email(和email_verified(如果有)
客户注册
注册 OAuth 客户端( Admin)
// convex/oauthAdmin.ts
import { mutation } from "./_generated/server";
import { OAuthProvider } from "@codefox-inc/oauth-provider";
import { components } from "./_generated/api";
export const registerOAuthClient = mutation({
handler: async (ctx, args: {
name: string;
redirectUris: string[];
scopes: string[];
type: "confidential" | "public";
}) => {
// Check admin permissions
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthorized");
const oauthProvider = new OAuthProvider(components.oauthProvider, {
privateKey: process.env.JWT_PRIVATE_KEY!,
jwks: process.env.JWKS!,
siteUrl: process.env.SITE_URL!,
});
const result = await oauthProvider.registerClient(ctx, {
name: args.name,
redirectUris: args.redirectUris,
scopes: args.scopes,
type: args.type,
});
// IMPORTANT: Save clientSecret securely - it's only returned once!
return result;
},
});
授权流量
自动授权处理器
这个 /oauth/authorize 端点自动处理完整的授权流 :
GET /oauth/authorize?
response_type=code
&client_id=CLIENT_ID
&redirect_uri=REDIRECT_URI
&scope=openid+profile+email
&resource=https://api.example.com/mcp
&state=STATE
&code_challenge=CHALLENGE
&code_challenge_method=S256
&nonce=NONCE
处理者:
- 验证客户端身份
- 对照已注册的 URI 检查方向调整 uri
- 验证请求的范围
- 需要 PKCE( 代码- 挑战)
- 验证和装订
resource提供时 - 通过
getUserId - 发布授权代码
- 用代码重定向回客户端
自定义授权流程( 高级)
如果您需要自定义同意, 您可以直接使用 SDK 方法 :
// convex/oauth.ts
import { mutation } from "./_generated/server";
import { OAuthProvider } from "@codefox-inc/oauth-provider";
import { components } from "./_generated/api";
export const approveAuthorization = mutation({
handler: async (ctx, args: {
clientId: string;
scopes: string[];
redirectUri: string;
codeChallenge: string;
codeChallengeMethod: string;
nonce?: string;
resource?: string;
}) => {
// Verify user is authenticated
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
const oauthProvider = new OAuthProvider(components.oauthProvider, {
privateKey: process.env.JWT_PRIVATE_KEY!,
jwks: process.env.JWKS!,
siteUrl: process.env.SITE_URL!,
});
// Issue authorization code (automatically creates authorization record)
const authCode = await oauthProvider.issueAuthorizationCode(ctx, {
userId: identity.subject,
clientId: args.clientId,
scopes: args.scopes,
redirectUri: args.redirectUri,
codeChallenge: args.codeChallenge,
codeChallengeMethod: args.codeChallengeMethod,
nonce: args.nonce,
resource: args.resource,
});
return authCode;
},
});
当授权请求包含 resource把它显示在同意书上 并且通过它没有改变。 如果象征性请求要求 resource 未存储在授权代码中, 符号端点返回 invalid_target。 。 。 .
授权管理
列表用户授权的应用程序
import { query } from "./_generated/server";
import { OAuthProvider } from "@codefox-inc/oauth-provider";
import { components } from "./_generated/api";
export const listAuthorizedApps = query({
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return [];
const oauthProvider = new OAuthProvider(components.oauthProvider, {
privateKey: process.env.JWT_PRIVATE_KEY!,
jwks: process.env.JWKS!,
siteUrl: process.env.SITE_URL!,
});
return await oauthProvider.listUserAuthorizations(ctx, identity.subject);
},
});
撤销授权
import { mutation } from "./_generated/server";
import { OAuthProvider } from "@codefox-inc/oauth-provider";
import { components } from "./_generated/api";
export const revokeApp = mutation({
handler: async (ctx, args: { clientId: string }) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
const oauthProvider = new OAuthProvider(components.oauthProvider, {
privateKey: process.env.JWT_PRIVATE_KEY!,
jwks: process.env.JWKS!,
siteUrl: process.env.SITE_URL!,
});
// Deletes authorization and all associated tokens
await oauthProvider.revokeAuthorization(ctx, identity.subject, args.clientId);
},
});
配置选项( OAuthConfig)
interface OAuthConfig {
// REQUIRED: RSA private key in PEM format
privateKey: string;
// REQUIRED: JWKS for token verification (public keys only)
jwks: string;
// REQUIRED: Your application URL
siteUrl: string;
// OPTIONAL: Convex deployment URL (if different from siteUrl)
convexSiteUrl?: string;
// OPTIONAL: OAuth endpoint prefix (default: "/oauth")
// Normalized to a leading slash, trailing slash removed; "/" means root.
// Must match the route prefix you register in http.ts.
prefix?: string;
// OPTIONAL: Comma-separated list of allowed CORS origins
allowedOrigins?: string;
// OPTIONAL: Allowed scopes for dynamic client registration
allowedScopes?: string[];
// OPTIONAL: JWT audience claim (default: "convex")
// Set to "oauth-provider" when using Better Auth to distinguish tokens
applicationID?: string;
// REQUIRED: Function to get authenticated user ID
// Must return a Convex users table Id (string)
// Returns null if user is not authenticated
getUserId?: (ctx: ActionCtx, request: Request) => Promise<string | null> | string | null;
// OPTIONAL: Enable dynamic client registration (default: false)
allowDynamicClientRegistration?: boolean;
}
调试验证
撤销和访问托肯寿命
访问符是JWT,可以无国籍地与JWKS验证. 仅凭无国籍核查,无法观察授权撤销、授权码重放检测或刷新式家庭撤销,直到访问令牌到期.
Convex 资源服务器, 有线 createAuthorizationChecker() 输入 createAuthHelper() 因此不记名请求检查当前授权记录:
import { createAuthHelper, OAuthProvider } from "@codefox-inc/oauth-provider";
import { components } from "./_generated/api";
const oauthProvider = new OAuthProvider(components.oauthProvider, {
privateKey: process.env.JWT_PRIVATE_KEY!,
jwks: process.env.JWKS!,
siteUrl: process.env.SITE_URL!,
});
export const authHelper = createAuthHelper({
providers: ["anonymous"],
checkAuthorization: oauthProvider.createAuthorizationChecker(),
});
如果您在 Convex 外验证访问符 verifyAccessToken() 只有被吊销的访问令牌有效,直到其 exp 时间。 如果需要立即吊销,则使用简短的访问存储寿命并使用资源服务器授权检查.
更新此组件后, 重新运行 Convex 代码生成( 例如) convex dev --once 或您的仓库的代码脚本。 生成的组件参考和计划必须与软件包版本相匹配; prompt=none 静态授权还依赖于最近生成的 getAuthorization 查询参考.
在凸起函数中
import { query } from "./_generated/server";
export const protectedQuery = query({
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
// Token is already verified by Convex Auth
// Use identity.subject for user ID
return { userId: identity.subject };
},
});
外部调试验证
import { verifyAccessToken } from "@codefox-inc/oauth-provider";
const payload = await verifyAccessToken(
token,
{
jwks: process.env.JWKS!,
siteUrl: process.env.SITE_URL!,
// If using Better Auth, specify the applicationID
// applicationID: "oauth-provider",
},
issuerUrl
);
console.log("User ID:", payload.sub);
console.log("Scopes:", payload.scp);
console.log("Client ID:", payload.cid);
区分 OAuth 从会话托肯
当使用多个认证系统(例如:Better Auth + OAuth Profer)时,您可以通过检查发件人来区分符:
import { isOAuthToken, getOAuthClientId } from "@codefox-inc/oauth-provider";
// Option 1: Using helper functions
const identity = await ctx.auth.getUserIdentity();
if (isOAuthToken(identity)) {
const clientId = getOAuthClientId(identity);
// Handle OAuth token (MCP clients, third-party apps)
} else {
// Handle session token (first-party users)
}
// Option 2: Check issuer directly
if (identity?.issuer?.includes("/oauth")) {
// This is an OAuth token
}
测试
bun run test
许可证
阿帕奇 - 2.0