@codefox-inc/oauth-provider代码库
OAuth 2.1/OpenID连接提供程序实现为Converx组件。
⚠️ 测试版软件 -生产使用风险由您自行承担。
经过测试 凸认证 和 @凸dev/beer认证.
为什么?
大多数MCP客户端(如Claude Code或ChatGPT)要求您的应用程序是OAuth提供者。如果你想将你的Converx应用程序连接到MCP客户端,你需要实现OAuth 2.1。
此组件将您的Converx应用程序转换为完全兼容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 Compliance
此实现如下 OAuth 2.1 以及相关的OAuth/OIDC规范:
支持的资助类型
- ✅ 带有PKCE的授权码 (公共和机密客户)
- ✅ 刷新令牌 (带代币轮换)
不支持的功能(OAuth 2.0旧版)
- ❌ 隐性补助 (出于安全原因在OAuth 2.1中删除)
- ❌ 户密码对授权 (在OAuth 2.1中删除)
- ❌ PKCE普通法 (根据OAuth 2.1最佳实践,仅支持S256)
关键安全要求
- PKCE执行:所有授权码流都需要使用S256方法的PKCE
- 重定向URI验证:字符串完全匹配(仅限RFC 8252环回变量端口异常)
- 资源绑定:
resource值绑定到授权授予和刷新令牌 - 访问令牌受众:访问令牌
aud是否获得授权resource,或配置的默认受众 - 授权码:一次性使用,10分钟后过期
- 令牌哈希:所有令牌都存储为SHA-256哈希值
- 刷新令牌轮换:每次使用时都会发出新的刷新令牌,旧令牌无效
Security Features
内置安全控制
- PKCE执行:所有授权码流都需要PKCE(code_challenge/code_verifier)
- 重定向URI验证:严格检查已注册的URI
- 范围验证:每个客户端只允许使用已注册的作用域
- 令牌哈希:访问和刷新令牌存储为SHA-256哈希值
- 客户端密钥哈希:机密客户机密使用bcrypt
- 内部突变:关键操作,如
issueAuthorizationCode无法直接访问 - DCR默认禁用:必须明确启用动态客户端注册
授权流安全
这 /oauth/authorize 端点执行全面验证:
- 客户端ID验证
- 根据注册的URI重定向URI匹配
- 根据客户端允许的作用域进行作用域验证
- PKCE要求(S256方法的代码挑战)
- 通过以下方式进行用户身份验证
getUserId钩子
Scopes and Token Types
支持的范围
openid:OpenID Connect身份验证和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规范,确保只有在用户明确同意的情况下才能颁发长期刷新令牌。
Resource Indicators and Audience Binding
此提供程序支持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:使用凸认证
如果你正在使用 凸认证,您已经配置了所需的环境变量(JWT_PRIVATE_KEY, JWKS, SITE_URL).
选项B:使用更好的身份验证
如果你正在使用 @凸dev/beer认证,您可以共享相同的密钥:
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"重要提示: 使用Better Auth时,设置 applicationID: "oauth-provider" 在OAuthProvider配置中,将OAuth令牌与Better Auth会话令牌区分开来。
Option C: Manual Setup
手动生成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;Option C: Manual Route Registration
// 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:添加name,pictureemail:添加email(以及email_verified如果可用)
Client Registration
注册OAuth客户端(管理员)
// 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处理程序:
- 验证客户端ID
- 根据注册的uri检查redirect_uri
- 验证请求的范围
- 需要PKCE(代码挑战)
- 验证并绑定
resource如果提供 - 通过以下方式对用户进行身份验证
getUserId - 发布授权码
- 使用代码重定向回客户端
Custom Authorization Flow (Advanced)
如果需要自定义同意UI,可以直接使用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,在同意UI中显示并原封不动地传递。如果令牌请求要求 resource 如果未存储在授权码中,令牌端点将返回 invalid_target.
Authorization Management
列出用户的授权应用程序
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);
},
});Configuration Options (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;
// OPTIONAL: Enable dynamic client registration (default: false)
allowDynamicClientRegistration?: boolean;
}令牌验证
凸函数中
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 };
},
});External Token Verification
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);Distinguishing OAuth Tokens from Session Tokens
当使用多个身份验证系统(例如,Better auth+OAuth Provider)时,您可以通过检查颁发者来区分令牌:
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
