cloudflare/workers-oauth-provider
GitHub: cloudflare/workers-oauth-provider
面向 Cloudflare Workers 的 OAuth 2.1 Provider 框架,让你在边缘平台上快速构建符合 MCP 规范的自有授权服务器。
Stars: 1735 | Forks: 113
# 适用于 Cloudflare Workers 的 OAuth 2.1 Provider 框架
这是一个 TypeScript 库,实现了支持 PKCE 的 OAuth 2.1 协议 Provider 端。该库旨在用于 Cloudflare Workers。
## 此库的优势
- 该库充当 Worker 代码的封装器,为您的 API 端点添加授权功能。
- 所有令牌管理均自动处理。
- 您的 API 处理程序编写方式类似常规的 fetch 处理程序,但接收已认证的用户详细信息作为参数。无需自行执行任何检查。
- 该库与您管理和认证用户的方式无关。
- 该库与您构建 UI 的方式无关。您的授权流程可以使用您用于其他任何内容的任何 UI 框架来实现。
- 该库的存储不存储任何秘密,仅存储它们的哈希值。
## 用法
使用该库的 Worker 可能如下所示:
```
import { OAuthProvider } from '@cloudflare/workers-oauth-provider';
import { WorkerEntrypoint } from 'cloudflare:workers';
// We export the OAuthProvider instance as the entrypoint to our Worker. This means it
// implements the `fetch()` handler, receiving all HTTP requests.
export default new OAuthProvider({
// Configure API routes. Any requests whose URL starts with any of these prefixes will be
// considered API requests. The OAuth provider will check the access token on these requests,
// and then, if the token is valid, send the request to the API handler.
// You can provide:
// - A single route (string) or multiple routes (array)
// - Full URLs (which will match the hostname) or just paths (which will match any hostname)
apiRoute: [
'/api/', // Path only - will match any hostname
'https://api.example.com/', // Full URL - will check hostname
],
// When the OAuth system receives an API request with a valid access token, it passes the request
// to this handler object's fetch method.
// You can provide either an object with a fetch method (ExportedHandler)
// or a class extending WorkerEntrypoint.
apiHandler: ApiHandler, // Using a WorkerEntrypoint class
// For multi-handler setups, you can use apiHandlers instead of apiRoute+apiHandler.
// This allows you to use different handlers for different API routes.
// Note: You must use either apiRoute+apiHandler (single-handler) OR apiHandlers (multi-handler), not both.
// Example:
// apiHandlers: {
// "/api/users/": UsersApiHandler,
// "/api/documents/": DocumentsApiHandler,
// "https://api.example.com/": ExternalApiHandler,
// },
// Any requests which aren't API request will be passed to the default handler instead.
// Again, this can be either an object or a WorkerEntrypoint.
defaultHandler: defaultHandler, // Using an object with a fetch method
// This specifies the URL of the OAuth authorization flow UI. This UI is NOT implemented by
// the OAuthProvider. It is up to the application to implement a UI here. The only reason why
// this URL is given to the OAuthProvider is so that it can implement the RFC-8414 metadata
// discovery endpoint, i.e. `.well-known/oauth-authorization-server`.
// Can also be specified as just a path (e.g., "/authorize").
authorizeEndpoint: 'https://example.com/authorize',
// This specifies the OAuth 2 token exchange endpoint. The OAuthProvider will implement this
// endpoint (by directly responding to requests with a matching URL).
// Can also be specified as just a path (e.g., "/oauth/token").
tokenEndpoint: 'https://example.com/oauth/token',
// This specifies the RFC-7591 dynamic client registration endpoint. This setting is optional,
// but if provided, the OAuthProvider will implement this endpoint to allow dynamic client
// registration.
// Can also be specified as just a path (e.g., "/oauth/register").
clientRegistrationEndpoint: 'https://example.com/oauth/register',
// Optional list of scopes supported by this OAuth provider.
// If provided, this will be included in the RFC 8414 metadata as 'scopes_supported'.
// If not provided, the 'scopes_supported' field will be omitted from the metadata.
scopesSupported: ['document.read', 'document.write', 'profile'],
// Optional: Controls whether the OAuth implicit flow is allowed.
// The implicit flow is discouraged in OAuth 2.1 but may be needed for some clients.
// Defaults to false.
allowImplicitFlow: false,
// Optional: Controls whether the plain PKCE code_challenge_method is allowed.
// OAuth 2.1 recommends using S256 exclusively as plain offers no cryptographic protection.
// When false, only S256 is accepted and advertised in the metadata endpoint.
// Defaults to true for backward compatibility.
allowPlainPKCE: true,
// Optional: Controls whether public clients (clients without a secret, like SPAs)
// can register via the dynamic client registration endpoint.
// When true, only confidential clients can register.
// Note: Creating public clients via the OAuthHelpers.createClient() method
// is always allowed regardless of this setting.
// Defaults to false.
disallowPublicClientRegistration: false,
// Optional: Time-to-live for refresh tokens in seconds.
// If not specified, refresh tokens do not expire.
// Set to 0 to disable refresh tokens (only access tokens will be issued).
// For example: 3600 = 1 hour, 86400 = 1 day, 2592000 = 30 days
refreshTokenTTL: 2592000, // 30 days
// Optional: Time-to-live for access tokens in seconds.
// Defaults to 1 hour (3600 seconds) if not specified.
accessTokenTTL: 3600,
// Optional: Controls whether OAuth 2.0 Token Exchange (RFC 8693) is allowed.
// When false, the token exchange grant type will not be advertised in metadata
// and token exchange requests will be rejected.
// Defaults to false.
allowTokenExchangeGrant: false,
// Optional: Explicitly enable Client ID Metadata Document (CIMD) support.
// When true, URL-formatted client_ids will be fetched as metadata documents.
// Requires the 'global_fetch_strictly_public' compatibility flag.
// See the CIMD section below for details. Defaults to false.
clientIdMetadataDocumentEnabled: false,
});
// The default handler object - the OAuthProvider will pass through HTTP requests to this object's fetch method
// if they aren't API requests or do not have a valid access token
const defaultHandler = {
// This fetch method works just like a standard Cloudflare Workers fetch handler
//
// The `request`, `env`, and `ctx` parameters are the same as for a normal Cloudflare Workers fetch
// handler, and are exactly the objects that the `OAuthProvider` itself received from the Workers
// runtime.
//
// The `env.OAUTH_PROVIDER` provides an API by which the application can call back to the
// OAuthProvider.
async fetch(request: Request, env, ctx) {
let url = new URL(request.url);
if (url.pathname == '/authorize') {
// This is a request for our OAuth authorization flow UI. It is up to the application to
// implement this. However, the OAuthProvider library provides some helpers to assist.
// `env.OAUTH_PROVIDER.parseAuthRequest()` parses the OAuth authorization request to extract the parameters
// required by the OAuth 2 standard, namely response_type, client_id, redirect_uri, scope, and
// state. It returns an object containing all these (using idiomatic camelCase naming).
let oauthReqInfo = await env.OAUTH_PROVIDER.parseAuthRequest(request);
// `env.OAUTH_PROVIDER.lookupClient()` looks up metadata about the client, as definetd by RFC-7591. This
// includes things like redirect_uris, client_name, logo_uri, etc.
let clientInfo = await env.OAUTH_PROVIDER.lookupClient(oauthReqInfo.clientId);
// At this point, the application should use `oauthReqInfo` and `clientInfo` to render an
// authorization consent UI to the user. The details of this are up to the app so are not
// shown here.
// After the user has granted consent, the application calls `env.OAUTH_PROVIDER.completeAuthorization()` to
// grant the authorization.
let { redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({
// The application passes back the original OAuth request info that was returned by
// `parseAuthRequest()` earlier.
request: oauthReqInfo,
// The application must specify the user's ID, which is some sort of string. This is needed
// so that the application can later query the OAuthProvider to enumerate all grants
// belonging to a particular user, e.g. to implement an audit and revocation UI.
userId: '1234',
// The application can specify some arbitary metadata which describes this grant. The
// metadata can contain any JSON-serializable content. This metadata is not used by the
// OAuthProvider, but the application can read back the metadata attached to specific
// grants when enumerating them later, again e.g. to implement an udit and revocation UI.
metadata: { label: 'foo' },
// The application specifies the list of OAuth scope identifiers that were granted. This
// may or may not be the same as was requested in `oauthReqInfo.scope`.
scope: ['document.read', 'document.write'],
// `props` is an arbitrary JSON-serializable object which will be passed back to the API
// handler for every request authorized by this grant.
props: {
userId: 1234,
username: 'Bob',
},
});
// `completeAuthorization()` will have returned the URL to which the user should be redirected
// in order to complete the authorization flow. This is the requesting client's OAuth
// redirect_uri with the appropriate query parameters added to complete the flow and obtain
// tokens.
return Response.redirect(redirectTo, 302);
}
// ... the application can implement other non-API HTTP endpoints here ...
return new Response('Not found', { status: 404 });
},
};
// The API handler object - the OAuthProivder will pass authorized API requests to this object's fetch method
// (because we provided it as the `apiHandler` setting, above). This is ONLY called for API requests
// that had a valid access token.
class ApiHandler extends WorkerEntrypoint {
// This fetch method works just like any other WorkerEntrypoint fetch method. The `request` is
// passed as a parameter, while `env` and `ctx` are available as `this.env` and `this.ctx`.
//
// The `this.env.OAUTH_PROVIDER` is available just like in the default handler.
//
// The `this.ctx.props` property contains the `props` value that was passed to
// `env.OAUTH_PROVIDER.completeAuthorization()` during the authorization flow that authorized this client.
fetch(request: Request) {
// The application can implement its API endpoints like normal. This app implements a single
// endpoint, `/api/whoami`, which returns the user's authenticated identity.
let url = new URL(request.url);
if (url.pathname == '/api/whoami') {
// Since the username is embedded in `ctx.props`, which came from the access token that the
// OAuthProivder already verified, we don't need to do any other authentication steps.
return new Response(`You are authenticated as: ${this.ctx.props.username}`);
}
return new Response('Not found', { status: 404 });
}
}
```
此实现要求您的 Worker 配置有一个名为 `OAUTH_KV` 的 Workers KV namespace 绑定,用于存储令牌信息。有关此 namespace 的架构详细信息,请参阅文件 `storage-schema.md`。
Fetch 处理程序可用的 `env.OAUTH_PROVIDER` 对象提供了一些查询存储的方法,包括:
- 创建、列出、修改和删除 client_id 注册(除了示例代码中已显示的 `lookupClient()` 之外)。
- 列出特定用户的所有活动授权许可。
- 撤销(删除)授权许可。
有关完整的 API 详细信息,请参阅 `OAuthHelpers` 接口定义。
## Token Exchange 回调
此库允许您通过配置回调函数在 token exchange 期间更新 `props` 值。这对于应用程序需要在颁发或刷新令牌时执行额外处理的场景非常有用。
例如,如果您的应用程序同时也是某个其他 OAuth API 的客户端,您可能希望执行等效的上游 token exchange 并将结果存储在 `props` 中。该回调可用于更新许可记录和特定访问令牌的 props。
要使用此功能,请在您的 OAuthProvider 选项中提供一个 `tokenExchangeCallback`:
```
new OAuthProvider({
// ... other options ...
tokenExchangeCallback: async (options) => {
// options.grantType is either 'authorization_code' or 'refresh_token'
// options.props contains the current props
// options.clientId, options.userId, and options.scope are also available
if (options.grantType === 'authorization_code') {
// For authorization code exchange, might want to obtain upstream tokens
const upstreamTokens = await exchangeUpstreamToken(options.props.someCode);
return {
// Update the props stored in the access token
accessTokenProps: {
...options.props,
upstreamAccessToken: upstreamTokens.access_token,
},
// Update the props stored in the grant (for future token refreshes)
newProps: {
...options.props,
upstreamRefreshToken: upstreamTokens.refresh_token,
},
};
}
if (options.grantType === 'refresh_token') {
// For refresh token exchanges, might want to refresh upstream tokens too
const upstreamTokens = await refreshUpstreamToken(options.props.upstreamRefreshToken);
return {
accessTokenProps: {
...options.props,
upstreamAccessToken: upstreamTokens.access_token,
},
newProps: {
...options.props,
upstreamRefreshToken: upstreamTokens.refresh_token || options.props.upstreamRefreshToken,
},
// Optionally override the default access token TTL to match the upstream token
accessTokenTTL: upstreamTokens.expires_in,
};
}
},
});
```
该回调可以:
- 同时返回 `accessTokenProps` 和 `newProps` 以更新两者
- 仅返回 `accessTokenProps` 以仅更新当前访问令牌
- 仅返回 `newProps` 以同时更新许可和访问令牌(访问令牌继承这些 props)
- 返回 `accessTokenTTL` 以覆盖此特定访问令牌的默认 TTL
- 返回 `refreshTokenTTL` 以覆盖此特定刷新令牌的默认 TTL
- 不返回任何内容以保持原始 props 不变
当应用程序同时也是另一服务的 OAuth 客户端并希望将其访问令牌 TTL 与上游访问令牌 TTL 匹配时,`accessTokenTTL` 覆盖特别有用。这有助于防止下游令牌仍然有效但上游令牌已过期的情况。
`props` 值是端到端加密的,因此它们可以安全地包含敏感信息。
## 自定义错误响应
通过使用 `onError` 选项,您可以在即将发出错误响应时发出通知或采取其他操作:
```
new OAuthProvider({
// ... other options ...
onError({ code, description, status, headers }) {
Sentry.captureMessage(/* ... */);
},
});
```
通过返回一个 `Response`,您还可以覆盖 OAuthProvider 返回给用户的内容:
```
new OAuthProvider({
// ... other options ...
onError({ code, description, status, headers }) {
if (code === 'unsupported_grant_type') {
return new Response('...', { status, headers });
}
// returning undefined (i.e. void) uses the default Response generation
},
});
```
默认情况下,`onError` 回调设置为 ``({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`)``。
## 受保护资源元数据 (RFC 9728)
该库自动提供 `/.well-known/oauth-protected-resource` 端点。默认情况下,它使用请求来源作为资源标识符,使用令牌端点的来源作为授权服务器。您可以使用 `resourceMetadata` 选项自定义此设置:
```
new OAuthProvider({
// ... other options ...
resourceMetadata: {
resource: 'https://api.example.com',
authorization_servers: ['https://auth.example.com'],
scopes_supported: ['read', 'write'],
bearer_methods_supported: ['header'],
resource_name: 'My API',
},
});
```
## 标准合规性
此库实现了以下 OAuth 和 MCP 规范:
- [OAuth 2.1 (draft-ietf-oauth-v2-1-13)](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13) — 支持 PKCE 的核心授权框架
- [OAuth 2.0 Authorization Server Metadata (RFC 8414)](https://datatracker.ietf.org/doc/html/rfc8414) — `/.well-known/oauth-authorization-server` 发现端点
- [OAuth 2.0 Protected Resource Metadata (RFC 9728)](https://datatracker.ietf.org/doc/html/rfc9728) — `/.well-known/oauth-protected-resource` 发现端点
- [OAuth 2.0 Dynamic Client Registration (RFC 7591)](https://datatracker.ietf.org/doc/html/rfc7591) — 动态客户端注册端点
- [OAuth Client ID Metadata Documents (draft-ietf-oauth-client-id-metadata-document)](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document) — HTTPS URL 作为 client ID
这些是 [MCP 授权规范](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)所要求的规范。
## 实现说明
### 端到端加密
此库在 KV 中存储有关授权令牌的记录。存储架构经过精心设计,使得存储的完全泄露只会揭示有关已授权内容的普通元数据。特别是:
- 秘密(包括访问令牌、刷新令牌、授权码和客户端秘密)仅通过哈希存储。因此,仅从存储中无法推导出此类秘密。
- 与许可关联的 `props`(在执行 API 请求时传回应用程序)使用秘密令牌作为密钥材料进行加密存储。因此,除非提供有效令牌,否则无法从存储中推导出 `props` 的内容。
请注意,与每个许可关联的 `userId` 和 `metadata` 未加密,因为这些值的目的是允许枚举许可以进行审计和撤销。然而,这些值对库来说完全不透明。应用程序可以自由地省略它们,或在将它们传递给库之前对其应用自己的加密(如果需要)。
### 单次使用刷新令牌?
OAuth 2.1 要求刷新令牌要么在加密上“绑定”到客户端,要么是单次使用的。此库目前未实现任何加密绑定,因此似乎需要单次使用令牌。在此要求下,每次令牌刷新请求都会使旧的刷新令牌失效并颁发一个新的。
这个要求似乎存在根本性的缺陷,因为它假设每个刷新请求都会无错误地完成。在现实世界中,瞬时的网络错误、机器故障或软件故障可能意味着客户端在刷新请求后无法存储新的刷新令牌。在这种情况下,客户端将永久无法发出任何进一步的请求,因为它拥有的唯一令牌不再有效。
此库实现了一种折衷方案:在任何特定时间,一个许可可能有两个有效的刷新令牌。当客户端使用其中一个时,另一个失效,并生成并返回一个新的。因此,如果客户端每次都正确使用新的刷新令牌,那么较旧的刷新令牌会不断失效。但是,如果瞬时故障阻止客户端更新其令牌,它始终可以使用它之前使用的令牌重试请求。
## Client ID Metadata Document (CIMD) 支持
此库支持 [Client ID Metadata Documents](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document),允许客户端使用 HTTPS URL 作为其 `client_id`。当客户端提供一个带有非根路径的 HTTPS URL 作为其 `client_id` 时,该库将从该 URL 获取并验证元数据文档。
### 启用 CIMD
CIMD 支持是可选的,需要两件事:
1. 在您的 OAuthProvider 选项中设置 `clientIdMetadataDocumentEnabled: true`:
```
new OAuthProvider({
// ... other options ...
clientIdMetadataDocumentEnabled: true,
});
```
2. 将 `global_fetch_strictly_public` 兼容性标志添加到您的 `wrangler.jsonc` 中:
```
{
"compatibility_flags": ["global_fetch_strictly_public"],
}
```
该兼容性标志是 SSRF(服务器端请求伪造)保护所必需的。由于历史遗留原因,对您 zone 域内 URL 的 `fetch()` 请求会直接发送到源服务器,绕过 Cloudflare。`global_fetch_strictly_public` 标志禁用此行为。有关更多详细信息,请参阅 [Cloudflare 文档](https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public)。
当未启用 CIMD(默认)时,URL 格式的 `client_id` 值会回退到标准 KV 查找。启用后,如果获取元数据文档失败,库会记录警告并返回 `invalid_client` 错误,允许 MCP 客户端通过回退到动态客户端注册来恢复。
只有当选项启用且兼容性标志存在时,OAuth 元数据端点才会报告 `client_id_metadata_document_supported: true`。
## 使用 Claude 编写
此库(包括架构文档)主要是在 Anthropic 的 AI 模型 [Claude](https://claude.ai) 的帮助下编写的。Claude 的输出经过了 Cloudflare 工程师的彻底审查,并仔细关注了安全性和标准合规性。在初始输出上进行了许多改进,主要是再次通过提示 Claude(并审查结果)完成的。查看提交历史,了解 Claude 是如何被提示的以及它生成了什么代码。
**“不!!!!你不能直接使用 LLM 来编写身份验证库!”**
“哈哈 GPU 轰隆隆”
说正经的,两个月前(2025 年 1 月),我([@kentonv](https://github.com/kentonv))也会同意这种看法。我曾经是一个 AI 怀疑论者。我认为 LLM 只是花哨的马尔可夫链生成器,并不真正理解代码,也无法产生任何新颖的东西。我一时兴起开始了这个项目,完全期望 AI 生成糟糕的代码供我嘲笑。然后,呃……代码看起来其实挺不错的。虽然不完美,但我只是告诉 AI 修复东西,它就做到了。我很震惊。
需要强调的是,**这并非“随意编程”**。每一行代码都经过了安全专家的彻底审查,并与相关 RFC 进行了交叉参考,这些专家具有这些 RFC 的先前经验。我原本是*试图*验证我的怀疑态度。结果我证明自己错了。
再次,请查看提交历史——尤其是早期提交——以了解这是如何进行的。
标签:Access Token, API 安全, JSONLines, Modbus, OAuth 2.1, PKCE, TypeScript, Worker Entrypoint, 中间件, 安全插件, 开源库, 授权框架, 搜索引擎爬虫, 无服务器, 程序员工具, 自动化攻击, 认证服务, 边缘计算, 零信任