@codefox-inc/oauth-provider

中文文档
0.4.2发布于 2026/05/17Apache-2.0TypeScriptES Module构建来源已验证近 30 天下载 17.3万

OAuth 2.1 / OpenID Connect Provider for Convex Auth (Beta).

原始使用文档
文档目录26 节
  1. Why?
  2. Installation
  3. Features
  4. Supported Grant Types
  5. Unsupported Features (OAuth 2.0 Legacy)
  6. Key Security Requirements
  7. Built-in Security Controls
  8. Authorization Flow Security
  9. Supported Scopes
  10. Refresh Token Requirements
  11. OAuth Token Detection Helper
  12. Setup
  13. 1. Set Environment Variables
  14. 2. Register Component
  15. 3. Configure HTTP Routes
  16. UserInfo Endpoint
  17. Register OAuth Client (Admin)
  18. Authorization Flow
  19. Automatic Authorization Handler
  20. List User's Authorized Apps
  21. Revoke Authorization
  22. Token Verification
  23. Revocation and Access Token Lifetime
  24. In Convex Functions
  25. Testing
  26. License

@codefox-inc/oauth-provider

OAuth 2.1 / OpenID Connect Provider implemented as a Convex component.

⚠️ Beta Software - Production use at your own risk.

Tested with Convex Auth and @convex-dev/better-auth.

Why?

Most MCP clients (like Claude Code or ChatGPT) require your app to be an OAuth provider. If you want to connect your Convex app to MCP clients, you need to implement OAuth 2.1.

This component turns your Convex app into a fully compliant OAuth 2.1 provider, so you can:

  • Connect to MCP clients out of the box
  • Let clients register automatically via Dynamic Client Registration
  • Let users control what permissions each app gets
  • Focus on your app, not OAuth complexity

Installation

bun add @codefox-inc/oauth-provider

Features

  • OAuth 2.1 compliant authorization and token endpoints
  • OpenID Connect Discovery for automatic client configuration
  • PKCE required for all authorization code flows (S256 only)
  • RFC 8707 resource indicators for audience-bound access tokens
  • RFC 9068 JWT access tokens (typ: at+jwt, client_id, scope, jti)
  • Secure token storage using SHA-256 hashing for tokens and authorization codes
  • JWT access tokens with RS256 signing
  • Refresh token rotation for enhanced security
  • Dynamic client registration (opt-in)
  • Authorization management for user consent tracking
  • JWKS endpoint for token verification
OAuth 2.1 Compliance

This implementation follows OAuth 2.1 and related OAuth/OIDC specifications:

Supported Grant Types

  • ✅ Authorization Code with PKCE (public and confidential clients)
  • ✅ Refresh Token (with token rotation)

Unsupported Features (OAuth 2.0 Legacy)

  • ❌ Implicit Grant (removed in OAuth 2.1 for security reasons)
  • ❌ Resource Owner Password Credentials Grant (removed in OAuth 2.1)
  • ❌ PKCE Plain Method (only S256 is supported per OAuth 2.1 best practices)

Key Security Requirements

  • PKCE Enforcement: All authorization code flows require PKCE with S256 method
  • Redirect URI Validation: Exact string matching (with RFC 8252 loopback variable port exception only)
  • Resource Binding: resource values are bound to the authorization grant and refresh token
  • Access Token Audience: Access token aud is the authorized resource, or the configured default audience
  • Authorization Code: Single-use, expires in 10 minutes
  • Token Hashing: All tokens stored as SHA-256 hashes
  • Refresh Token Rotation: New refresh token issued on each use, old token invalidated
Security Features

Built-in Security Controls

  • PKCE Enforcement: All authorization code flows require PKCE (code_challenge/code_verifier)
  • Redirect URI Validation: Strict checking against registered URIs
  • Scope Validation: Only registered scopes are allowed per client
  • Token Hashing: Access and refresh tokens are stored as SHA-256 hashes
  • Client Secret Hashing: Confidential client secrets use bcrypt
  • Client Secret Compatibility: Newly issued confidential client secrets fit within bcrypt's 72-byte input limit; existing longer secrets remain valid for patch-release compatibility and should be rotated when practical
  • Internal Mutations: Critical operations like issueAuthorizationCode are not directly accessible
  • DCR Disabled by Default: Dynamic Client Registration must be explicitly enabled

Authorization Flow Security

The /oauth/authorize endpoint performs comprehensive validation:

  1. Client ID verification
  2. Redirect URI matching against registered URIs
  3. Scope validation against client's allowed scopes
  4. PKCE requirement (code_challenge with S256 method)
  5. User authentication via getUserId hook
Scopes and Token Types

Supported Scopes

  • openid: Required for OpenID Connect authentication and ID tokens
  • profile: Grants access to user profile information (name, picture)
  • email: Grants access to user email address
  • offline_access: Enables refresh token issuance for long-lived access

Refresh Token Requirements

Refresh tokens are only issued when the offline_access scope is requested and granted during the initial authorization. For OpenID Connect requests, offline_access requires prompt=consent (or a space-delimited prompt value that includes consent):

  • ✅ With offline_access: Client receives both access token and refresh token
  • ❌ Without offline_access: Client receives only access token (no refresh token)

Refresh Token Grant Flow:

  • Use the refresh_token grant type to obtain new access tokens
  • The original authorization must have included the offline_access scope
  • Refresh tokens are automatically rotated on each use (old token is invalidated)
  • The new refresh token maintains the same scope as the original
  • Reuse of a rotated refresh token revokes the active refresh-token family and its authorization record

This follows OAuth 2.1 and OpenID Connect specifications, ensuring that long-lived refresh tokens are only issued with explicit user consent.

Resource Indicators and Audience Binding

This provider supports RFC 8707 resource indicators for MCP and other resource-server flows.

  • resource is optional on the authorization request.
  • If present, it must be an absolute URI without a fragment.
  • The authorization code stores the approved resource.
  • The token request may repeat the same resource, but cannot add a new one that was not approved.
  • Refresh tokens preserve the same resource/audience binding during rotation.
  • Access tokens use the authorized resource as aud; otherwise they use applicationID or the default convex audience.

For custom consent UIs, preserve the incoming resource parameter and pass it to issueAuthorizationCode.

OAuth Token Detection Helper

Provides helper functions to distinguish between OAuth tokens and session tokens:

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)
}

Setup

1. Set Environment Variables

This component works with any authentication system. Choose the setup that matches your stack:

Option A: With Convex Auth

If you're using Convex Auth, you already have the required environment variables configured (JWT_PRIVATE_KEY, JWKS, SITE_URL).

Option B: With Better Auth

If you're using @convex-dev/better-auth, you can share the same keys:

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"

Important: When using Better Auth, set applicationID: "oauth-provider" in your OAuthProvider config to distinguish OAuth tokens from Better Auth session tokens.

Option C: Manual Setup

Generate RSA keys manually:

# 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' }] }));
})();
"

Set environment variables:

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. Register Component

// 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. Configure HTTP Routes

// 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;

Option B: With Better Auth

// 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;

UserInfo Endpoint

Requires openid scope. Returns claims based on scopes:

  • openid: Always returns sub
  • profile: Adds name, picture
  • email: Adds email (and email_verified if available)
Client Registration

Register OAuth Client (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;
    },
});

Authorization Flow

Automatic Authorization Handler

The /oauth/authorize endpoint handles the complete authorization flow automatically:

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

The handler:

  1. Validates the client ID
  2. Checks redirect_uri against registered URIs
  3. Validates requested scopes
  4. Requires PKCE (code_challenge)
  5. Validates and binds resource when provided
  6. Authenticates the user via getUserId
  7. Issues authorization code
  8. Redirects back to the client with the code
Custom Authorization Flow (Advanced)

If you need custom consent UI, you can use the SDK methods directly:

// 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;
    },
});

When the authorization request contains resource, show it in the consent UI and pass it through unchanged. If the token request asks for a resource that was not stored on the authorization code, the token endpoint returns invalid_target.

Authorization Management

List User's Authorized Apps

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);
    },
});

Revoke Authorization

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> | string | null;

    // OPTIONAL: Enable dynamic client registration (default: false)
    allowDynamicClientRegistration?: boolean;
}

Token Verification

Revocation and Access Token Lifetime

Access tokens are JWTs and can be verified statelessly with the JWKS. Stateless verification alone cannot observe authorization revocation, authorization-code replay detection, or refresh-token family revocation until the access token expires.

For Convex resource servers, wire createAuthorizationChecker() into createAuthHelper() so bearer-token requests check the current authorization record:

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(),
});

If you verify access tokens outside Convex with verifyAccessToken() only, revoked access tokens remain valid until their exp time. Use short access-token lifetimes and a resource-server authorization check if immediate revocation is required.

After upgrading this component, rerun Convex code generation (for example convex dev --once or your repository's codegen script). The generated component references and schema must match the package version; prompt=none silent authorization also relies on the latest generated getAuthorization query reference.

In Convex Functions

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

When using multiple auth systems (e.g., Better Auth + OAuth Provider), you can distinguish tokens by checking the issuer:

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
}

Testing

bun run test

License

Apache-2.0

版本与发布记录

@codefox-inc/oauth-provider 共发布 11 个版本,首次发布于 2026/01/23;过去 12 个月发布了 11 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.4.2latest2026/05/175 个月前
0.4.12026/05/056 个月前
0.4.02026/05/056 个月前
0.3.22026/03/257 个月前
0.3.12026/03/207 个月前
0.3.02026/02/019 个月前

关于 @codefox-inc/oauth-provider 的常见问题

@codefox-inc/oauth-provider 是什么?

OAuth 2.1 / OpenID Connect Provider for Convex Auth (Beta).

如何安装 @codefox-inc/oauth-provider?

在项目目录运行 npm install @codefox-inc/oauth-provider,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:convex(^1.31.6)、react(^18.2.0 || ^19.0.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@codefox-inc/oauth-provider 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@codefox-inc/oauth-provider 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@codefox-inc/oauth-provider 可以商用吗?

@codefox-inc/oauth-provider 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@codefox-inc/oauth-provider 的下载量有多少?

根据 npm 官方下载统计,@codefox-inc/oauth-provider 在 2026/10/10 – 2026/11/08 这 30 天内被下载 172,999 次,日均约 5,767 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@codefox-inc/oauth-provider 还在维护吗?

最新版本 0.4.2 发布于 2026/05/17(5 个月前);至今共发布 11 个版本,其中过去 12 个月发布了 11 个;首次发布于 2026/01/23。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。