Token导航 LogoToken导航TokenDH.com
Convex Oauth Provider logo
安全风控未说明官方级别未说明来源级核验

Convex Oauth Provider

MCP Server

一个符合OAuth 2.1和OpenID Connect标准的身份验证组件,可将Convex应用转换为完整的OAuth提供者,适用于需要连接MCP客户端的场景。

工具数

0

提示词数

0

GitHub Stars

4

资源数

0
身份验证TypeScriptClaudeClaude

安装说明

本站只整理中文说明和来源信息,不托管安装包,也不代用户安装。

作者 / 组织

codefox-inc

提供方

codefox-inc

最后核验

2026/5/17 20:22

快速接入

先看主来源和安装命令,再打开仓库或文档;下面只保留这个条目的关键接入事实。

详细介绍

@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 端点执行全面验证:

  1. 客户端ID验证
  2. 根据注册的URI重定向URI匹配
  3. 根据客户端允许的作用域进行作用域验证
  4. PKCE要求(S256方法的代码挑战)
  5. 通过以下方式进行用户身份验证 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:始终返回 sub
  • profile:添加 name, picture
  • email:添加 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

处理程序:

  1. 验证客户端ID
  2. 根据注册的uri检查redirect_uri
  3. 验证请求的范围
  4. 需要PKCE(代码挑战)
  5. 验证并绑定 resource 如果提供
  6. 通过以下方式对用户进行身份验证 getUserId
  7. 发布授权码
  8. 使用代码重定向回客户端

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

目录标签

目录标签

身份验证TypeScriptClaude本地部署OAuth2.1OpenIDConnect安全令牌动态客户端注册

支持客户端

Claude

接入字段

传输方式(transport,传输协议)

未说明

鉴权方式(authType,认证方式)

oauth

工具数量(toolCount,工具数)

0

资源数量(resourceCount,资源数)

0

提示词数量(promptCount,提示词数)

0

权限和风险

未说明oauth部署方式未说明

接入前请确认传输方式、认证方式和部署位置,并根据实际工具能力限制访问范围。

安装前确认

不要直接授予不必要的文件、网络或账号权限;先核对安装命令和配置内容。

仍需确认:installCommand

来源信息

继续浏览同类 MCP