@cloudflare/workers-oauth-provider 0.10.4 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +200 -136
- package/dist/oauth-provider.d.ts +431 -88
- package/dist/oauth-provider.js +1756 -316
- package/docs/advanced-configuration.md +370 -0
- package/docs/consent-page.md +138 -0
- package/docs/migration-1.0.md +97 -0
- package/docs/resource-servers.md +153 -0
- package/docs/upstream-sign-in.md +93 -0
- package/package.json +4 -2
- package/skills/migrate-to-1.0/SKILL.md +35 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Resource servers
|
|
2
|
+
|
|
3
|
+
An `OAuthAuthorizationServer` issues tokens; a resource server accepts them. Every resource, whether it runs in the authorization server's Worker or in its own, is hosted the same way:
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
new OAuthResourceServer<Env, Props>({
|
|
7
|
+
resourceMetadata: { resource, authorization_servers: [issuer], scopes_supported: ['calendar:read'] },
|
|
8
|
+
validateToken: (env, request) => (resource, token) =>
|
|
9
|
+
Promise<{ props; audience; expiresAt?; scope?; userId?; clientId? } | null>,
|
|
10
|
+
handler: { fetch(request, env, ctx) {} }, // ctx.props: Props, ctx.auth: OAuthResourceAuth
|
|
11
|
+
});
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The host publishes RFC 9728 metadata at `/.well-known/oauth-protected-resource<path>`, answers unauthenticated requests with a Bearer challenge that names it and the `scopes_supported` to ask for, calls your validator with its own canonical resource and the presented token, refuses a result whose `audience` is not that resource, and answers `503` when the validator throws. Only `validateToken` changes between the topologies below.
|
|
15
|
+
|
|
16
|
+
## What the handler sees
|
|
17
|
+
|
|
18
|
+
`ctx.props` is the application data the validator returned. `ctx.auth` is what was verified about the token: `{ token, audience, expiresAt?, scope, userId?, clientId? }`. `OAuthAuthorizationServer.validateToken()` fills all of it; a validator of your own reports what it knows and `scope` defaults to `[]`.
|
|
19
|
+
|
|
20
|
+
Scope policy is the handler's. When a valid token lacks what an operation needs, answer with `insufficientScope`, which builds the MCP scope challenge — `403`, `error="insufficient_scope"`, every scope the operation requires, and the same `resource_metadata` URL the `401` advertised — so the client can step up in one round trip:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
handler: {
|
|
24
|
+
fetch(request, env, ctx) {
|
|
25
|
+
if (request.method === 'DELETE' && !ctx.auth.scope.includes('calendar:write')) {
|
|
26
|
+
return insufficientScope(ctx.auth, ['calendar:write']);
|
|
27
|
+
}
|
|
28
|
+
// …
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
A `WorkerEntrypoint` handler reads the same fields from `this.ctx`; declare it as `OAuthResourceContext<Props>` to type them. `OAuthProvider` sets `ctx.auth` for its `apiHandler` too, from its own token record, so a handler moves between the two hosts unchanged.
|
|
34
|
+
|
|
35
|
+
## Same Worker
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
const authorizationServer = new OAuthAuthorizationServer<Env>({
|
|
39
|
+
issuer: 'https://auth.example.com',
|
|
40
|
+
resources: ['https://calendar.example.com/mcp', 'https://drive.example.com/mcp'],
|
|
41
|
+
authorizeEndpoint: '/authorize',
|
|
42
|
+
tokenEndpoint: '/oauth/token',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const local = (env: Env) => (resource: string, token: string) =>
|
|
46
|
+
authorizationServer.validateToken(resource, token, env);
|
|
47
|
+
|
|
48
|
+
const calendar = new OAuthResourceServer<Env, AuthProps>({
|
|
49
|
+
resourceMetadata: {
|
|
50
|
+
resource: 'https://calendar.example.com/mcp',
|
|
51
|
+
authorization_servers: ['https://auth.example.com'],
|
|
52
|
+
},
|
|
53
|
+
validateToken: local,
|
|
54
|
+
handler: calendarHandler,
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
You own routing. This example (`npm install hono`) puts one Worker on three custom domains and uses Hono's hostname-aware path so no `switch` is needed; the original `Request` is forwarded as `c.req.raw` so URL validation sees the real origin:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
const app = new Hono<{ Bindings: Env }>({
|
|
62
|
+
getPath: (request) => `/${new URL(request.url).hostname}${new URL(request.url).pathname}`,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
app.get('/auth.example.com/authorize', async (c) => {
|
|
66
|
+
const oauth = authorizationServer.getOAuthApi(c.env);
|
|
67
|
+
const request = await oauth.parseAuthRequest(c.req.raw); // render AuthorizationError safely in production
|
|
68
|
+
const { redirectTo } = await oauth.completeAuthorization({
|
|
69
|
+
request,
|
|
70
|
+
userId: 'user-123',
|
|
71
|
+
metadata: {},
|
|
72
|
+
scope: request.scope,
|
|
73
|
+
props: { userId: 'user-123', scopes: request.scope },
|
|
74
|
+
});
|
|
75
|
+
return c.redirect(redirectTo);
|
|
76
|
+
});
|
|
77
|
+
app.all('/auth.example.com/*', (c) => authorizationServer.fetch(c.req.raw, c.env, c.executionCtx));
|
|
78
|
+
app.all('/calendar.example.com/*', (c) => calendar.fetch(c.req.raw, c.env, c.executionCtx));
|
|
79
|
+
app.all('/drive.example.com/*', (c) => drive.fetch(c.req.raw, c.env, c.executionCtx));
|
|
80
|
+
|
|
81
|
+
export default app;
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```jsonc
|
|
85
|
+
{
|
|
86
|
+
"workers_dev": false,
|
|
87
|
+
"routes": [
|
|
88
|
+
{ "pattern": "auth.example.com", "custom_domain": true },
|
|
89
|
+
{ "pattern": "calendar.example.com", "custom_domain": true },
|
|
90
|
+
{ "pattern": "drive.example.com", "custom_domain": true },
|
|
91
|
+
],
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Authorization server metadata advertises every declared resource in `protected_resources`; each resource publishes its own protected resource metadata pointing back at the issuer. `resources` is fixed at construction and `defaultResource` and `legacyGrantResource` are checked against it then; a resource server that asks about an undeclared resource gets a rejection, which the host turns into `503`.
|
|
96
|
+
|
|
97
|
+
## Separate Workers
|
|
98
|
+
|
|
99
|
+
Only the authorization server can validate a token: the props are encrypted with a key wrapped by the token itself, and the token record lives in its KV. A resource Worker therefore asks it, over a Service Binding.
|
|
100
|
+
|
|
101
|
+
Authorization Worker, exposing the method from a `WorkerEntrypoint`:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import { WorkerEntrypoint } from 'cloudflare:workers';
|
|
105
|
+
|
|
106
|
+
export default class AuthServer extends WorkerEntrypoint<Env> {
|
|
107
|
+
fetch(request: Request) {
|
|
108
|
+
// Your /authorize route goes here too; everything else is the authorization server's.
|
|
109
|
+
return authorizationServer.fetch(request, this.env, this.ctx);
|
|
110
|
+
}
|
|
111
|
+
validateToken(resource: string, token: string) {
|
|
112
|
+
return authorizationServer.validateToken(resource, token, this.env);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Resource Worker, with a binding to it:
|
|
118
|
+
|
|
119
|
+
```jsonc
|
|
120
|
+
{ "services": [{ "binding": "AUTH_SERVER", "service": "auth" }] }
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import { OAuthResourceServer, type AuthorizationServerBinding } from '@cloudflare/workers-oauth-provider';
|
|
125
|
+
|
|
126
|
+
interface Env {
|
|
127
|
+
AUTH_SERVER: AuthorizationServerBinding<AuthProps>; // or Service<AuthServer> from wrangler types
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export default new OAuthResourceServer<Env, AuthProps>({
|
|
131
|
+
resourceMetadata: {
|
|
132
|
+
resource: 'https://calendar.example.com/mcp',
|
|
133
|
+
authorization_servers: ['https://auth.example.com'],
|
|
134
|
+
},
|
|
135
|
+
validateToken: (env) => env.AUTH_SERVER.validateToken,
|
|
136
|
+
handler,
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The host calls the method it is handed with its resource and the token, so neither is repeated. The binding is not a URL: the validator is never exposed to the public internet, and the resource Worker cannot ask about another resource's tokens by accident because the host always passes its own. One RPC per request, on Cloudflare's network. `ctx.props` is the same `AuthProps` the authorization flow stored, decrypted by the authorization server; `ctx.auth` carries the token's scopes, subject and client back with it; and revocation is immediate.
|
|
141
|
+
|
|
142
|
+
## Another issuer, at your own risk
|
|
143
|
+
|
|
144
|
+
`validateToken` is just a function. A resource that accepts tokens from an authorization server that is not this package validates them itself — an RFC 7662 introspection call, a JWT library against that issuer's JWKS — and returns `{ props, audience, expiresAt }`:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
validateToken: (env) => async (resource, token) => {
|
|
148
|
+
const { payload } = await jwtVerify(token, keys, { issuer: OTHER_ISSUER, audience: resource, typ: 'at+jwt' });
|
|
149
|
+
return { props: { userId: payload.sub! }, audience: resource, expiresAt: payload.exp };
|
|
150
|
+
},
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The host still enforces the audience and expiry it is given, and it fails closed on a malformed `scope`, `userId` or `clientId`. Everything else about that issuer's tokens is between you and it. MCP's security guidance is blunt on the point that a resource server must accept only tokens issued for it; keep `audience` honest.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Signing in through another provider
|
|
2
|
+
|
|
3
|
+
Many MCP servers don't have their own users: they sign people in with GitHub, Sentry, Google, or another OAuth provider, and call that provider's API with the user's token. The MCP server is still the authorization server for MCP clients, but its `/authorize` page hands the user to the third party and finishes when the third party redirects back.
|
|
4
|
+
|
|
5
|
+
Every MCP client then reaches the third party through your one OAuth app. MCP's security best practices call this the [confused deputy problem](https://modelcontextprotocol.io/docs/2026-07-28/tutorials/security/security_best_practices#confused-deputy-problem) and require consent per client before the redirect, a consent page that can't be framed or forged, and a `state` bound to the user's browser. The helpers below implement those requirements; the consent page itself is yours.
|
|
6
|
+
|
|
7
|
+
## The flow
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
const oauth = authorizationServer.getOAuthApi(env); // or env.OAUTH_PROVIDER with OAuthProvider
|
|
11
|
+
|
|
12
|
+
// GET /authorize: parse, then ask for consent.
|
|
13
|
+
const request = await oauth.parseAuthRequest(req);
|
|
14
|
+
const client = await oauth.lookupClient(request.clientId);
|
|
15
|
+
const consent = await oauth.beginConsent(request);
|
|
16
|
+
return new Response(renderConsentPage({ client, request, handle: consent.handle }), {
|
|
17
|
+
headers: consent.headers, // binding cookie, no framing, no caching
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// POST /authorize: the user approved. Now, and only now, start the third-party redirect.
|
|
21
|
+
const form = await req.formData();
|
|
22
|
+
const approved = await oauth.approveConsent(req, String(form.get('handle')), {
|
|
23
|
+
scope: form.getAll('scope').map(String), // optional: the scopes the user ticked, from any in scopesSupported
|
|
24
|
+
});
|
|
25
|
+
const verifier = crypto.randomUUID() + crypto.randomUUID();
|
|
26
|
+
const { state, headers } = await oauth.beginUpstream(approved.request, {
|
|
27
|
+
data: { verifier }, // returned at the callback; never sent to the third party
|
|
28
|
+
headers: approved.headers,
|
|
29
|
+
});
|
|
30
|
+
headers.set('Location', githubAuthorizeUrl({ state, codeChallenge: await s256(verifier) }));
|
|
31
|
+
return new Response(null, { status: 302, headers });
|
|
32
|
+
|
|
33
|
+
// GET /callback: recover the request, exchange the third party's code, finish.
|
|
34
|
+
const { request: original, data, headers: clear } = await oauth.finishUpstream<{ verifier: string }>(req);
|
|
35
|
+
const upstream = await exchangeGithubCode(new URL(req.url).searchParams.get('code')!, data.verifier);
|
|
36
|
+
const { redirectTo } = await oauth.completeAuthorization({
|
|
37
|
+
request: original,
|
|
38
|
+
userId: upstream.user.id,
|
|
39
|
+
metadata: {},
|
|
40
|
+
scope: original.scope,
|
|
41
|
+
props: { githubToken: upstream.accessToken }, // encrypted; handlers get it in ctx.props
|
|
42
|
+
});
|
|
43
|
+
clear.set('Location', redirectTo);
|
|
44
|
+
return new Response(null, { status: 302, headers: clear });
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Build the consent page itself, and the Deny path (`denyConsent()`), as in [consent-page.md](consent-page.md), which also covers which errors to redirect and which to render. Validation failures throw `AuthorizationError` without a `redirectUri`: render them locally. A `TypeError` (bad options) or a storage failure is a bug or an outage, not the user's doing.
|
|
48
|
+
|
|
49
|
+
`beginUpstream()` doesn't check consent itself: call it only after `approveConsent()`, or when `isConsentRemembered()` says yes. (A server whose clients are all pre-registered, with no dynamic registration, isn't required to ask per client and can call it directly.)
|
|
50
|
+
|
|
51
|
+
If the third party sends the user back with an error (they declined there, or it failed), `finishUpstream()` still returns the original request, so answer the client with it:
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
const { request: original, headers } = await oauth.finishUpstream(req);
|
|
55
|
+
const error = new URL(req.url).searchParams.get('error');
|
|
56
|
+
if (error) {
|
|
57
|
+
const redirect = new URL(original.redirectUri);
|
|
58
|
+
redirect.searchParams.set('error', 'access_denied');
|
|
59
|
+
redirect.searchParams.set('state', original.state);
|
|
60
|
+
if (original.issuer) redirect.searchParams.set('iss', original.issuer);
|
|
61
|
+
headers.set('Location', redirect.href);
|
|
62
|
+
return new Response(null, { status: 302, headers });
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## What the helpers guarantee
|
|
67
|
+
|
|
68
|
+
- **The consent page can't be forged or framed.** `beginConsent()` binds the handle to the browser with a `__Host-` cookie (`Secure`, `HttpOnly`, `SameSite=Lax`, ten minutes) and returns `Content-Security-Policy: frame-ancestors 'none'` and `X-Frame-Options: DENY`. A post from another site has the handle but not the cookie, and is refused.
|
|
69
|
+
- **`state` exists only after consent.** `beginUpstream()` creates it, stores the approved request server-side, and binds it to the browser. The callback is refused without the matching cookie, so a stolen third-party code can't be replayed in another browser.
|
|
70
|
+
- **Single use, ten minutes, several at once.** Each handle and `state` works once, and each has its own binding cookie, so two tabs can authorize at the same time. KV keys hold only the SHA-256 of the handle, and the record, including your `data` (a PKCE verifier, say), is encrypted with a key only the handle derives: reading KV alone reveals nothing. KV can't make `get`-then-`delete` atomic, so two simultaneous requests from the _same_ browser with the same handle could both pass; the cookie binding rules out anyone else.
|
|
71
|
+
- **Nothing trusted comes from the form.** The authorization request is recovered from storage, not from hidden fields, so the page can't be made to approve a different client or redirect URI. `scope` is the page's to choose, fewer or more than the client requested, but only from `scopesSupported`.
|
|
72
|
+
|
|
73
|
+
## Remembering consent
|
|
74
|
+
|
|
75
|
+
Pass `remember` to `approveConsent()` and check `isConsentRemembered()` before showing the page; when it's remembered, go straight to `beginUpstream()`. See [consent-page.md](consent-page.md#remembering-consent), which also covers the cookie names.
|
|
76
|
+
|
|
77
|
+
## When the third party revokes access
|
|
78
|
+
|
|
79
|
+
Store the third party's refresh token in `props` and refresh it in `tokenExchangeCallback`. When it answers `invalid_grant`, the user has revoked your app or the token is gone for good. Throw `invalid_grant` too: the library revokes this grant, with its tokens, so the MCP client re-authorizes instead of retrying a grant that can never work. For a transient failure (the provider is down, rate limited), throw `temporarily_unavailable` instead, which leaves the grant for the retry.
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
tokenExchangeCallback: async ({ grantType, props }) => {
|
|
83
|
+
if (grantType !== 'refresh_token') return;
|
|
84
|
+
const upstream = await refreshGithubToken(props.githubRefreshToken);
|
|
85
|
+
if (upstream.error === 'bad_refresh_token') {
|
|
86
|
+
throw new OAuthError('invalid_grant', { description: 'GitHub access was revoked' }); // revokes this grant
|
|
87
|
+
}
|
|
88
|
+
if (!upstream.ok) {
|
|
89
|
+
throw new OAuthError('temporarily_unavailable', { description: 'GitHub is unavailable', statusCode: 503 });
|
|
90
|
+
}
|
|
91
|
+
return { newProps: { ...props, githubToken: upstream.accessToken, githubRefreshToken: upstream.refreshToken } };
|
|
92
|
+
},
|
|
93
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudflare/workers-oauth-provider",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "OAuth provider for Cloudflare Workers",
|
|
5
5
|
"main": "dist/oauth-provider.js",
|
|
6
6
|
"types": "dist/oauth-provider.d.ts",
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
"license": "MIT",
|
|
9
9
|
"sideEffects": false,
|
|
10
10
|
"files": [
|
|
11
|
-
"dist"
|
|
11
|
+
"dist",
|
|
12
|
+
"docs",
|
|
13
|
+
"skills"
|
|
12
14
|
],
|
|
13
15
|
"type": "module",
|
|
14
16
|
"publishConfig": {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: workers-oauth-provider-migrate-1.0
|
|
3
|
+
description: Migrate a Cloudflare Worker from @cloudflare/workers-oauth-provider 0.x to 1.0. Use when upgrading that dependency, when OAuthProvider construction throws about resourceMetadata.resource or resourceMatchOriginOnly, or when asked to adopt the 1.0 role-based API (OAuthAuthorizationServer / OAuthResourceServer).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Migrate @cloudflare/workers-oauth-provider 0.x → 1.0
|
|
7
|
+
|
|
8
|
+
The single source of truth for every change is the migration guide shipped with the package:
|
|
9
|
+
`node_modules/@cloudflare/workers-oauth-provider/docs/migration-1.0.md`
|
|
10
|
+
(also at https://github.com/cloudflare/workers-oauth-provider/blob/main/docs/migration-1.0.md).
|
|
11
|
+
Read it fully before editing. This skill is the procedure around it; do not work from memory of 0.x or from this file alone.
|
|
12
|
+
|
|
13
|
+
## Procedure
|
|
14
|
+
|
|
15
|
+
1. **Detect the shape.** Find `new OAuthProvider(` and read its options. The common shape is one Worker acting as authorization server and resource server; that shape stays on `OAuthProvider` in 1.0. Do not introduce `OAuthAuthorizationServer`/`OAuthResourceServer` unless the user asks for a multi-Worker or multi-resource topology.
|
|
16
|
+
2. **Choose the canonical resource — ask the user.** `resourceMetadata: { resource }` is required in 1.0. The value is the URL MCP clients connect to (often an existing `apiRoute` on the Worker's public origin, e.g. `https://mcp.example.com/mcp`). Infer a candidate from `wrangler.jsonc` routes/custom domains plus `apiRoute`, present it, and get confirmation — it becomes the token audience, so it must be right.
|
|
17
|
+
3. **Apply the guide's changes** that match the code: add `resourceMetadata.resource`; delete `resourceMatchOriginOnly`; make `resolveExternalToken` return the canonical `audience`; single-string `resource`/`aud` types; check `apiRoute`s are the resource path or descendants.
|
|
18
|
+
4. **Bump the dependency** to `^1.0.0` and install.
|
|
19
|
+
5. **Verify** (below), then walk the user through the guide's "Existing stored data" section so they know what their live clients will experience (nothing, in the common case).
|
|
20
|
+
|
|
21
|
+
## Stop and ask the user
|
|
22
|
+
|
|
23
|
+
- The canonical `resource` value (step 2). Never guess silently.
|
|
24
|
+
- On a multi-resource `OAuthAuthorizationServer`: which resource is `legacyGrantResource` (the migration destination for pre-1.0 grants). Omitting it makes old grants reauthorize.
|
|
25
|
+
- Any DCR client base registered with narrow `grant_types`: 1.0 enforces them; confirm the registered types cover what clients actually send before deploying.
|
|
26
|
+
- Adopting new 1.0 surface (role classes, `ctx.auth`, `insufficientScope`, `onError.internal`) is optional — offer, don't do unasked.
|
|
27
|
+
|
|
28
|
+
## Verify
|
|
29
|
+
|
|
30
|
+
1. `tsc`/typecheck and the project's tests pass.
|
|
31
|
+
2. `wrangler dev`, then:
|
|
32
|
+
- `curl -i http://localhost:8787<api route>` → 401 whose `WWW-Authenticate` names `resource_metadata="…/.well-known/oauth-protected-resource<resource path>"`. This works locally whatever the configured resource's origin.
|
|
33
|
+
- The metadata document itself is origin-strict (RFC 9728 §3): its well-known URL is `<resource origin>/.well-known/oauth-protected-resource<resource path>`. When the dev config's resource is on the loopback origin (e.g. `http://localhost:8787/mcp`), `curl http://localhost:8787/.well-known/oauth-protected-resource/mcp` → 200 with the exact `resource`. A production resource origin serves its document only there — after deploy: `curl https://<host>/.well-known/oauth-protected-resource<resource path>`.
|
|
34
|
+
- Construction errors surface on the first request and name the violated rule; fix per the guide.
|
|
35
|
+
3. If the deployment has live users, re-read "Existing stored data — nothing to do" in the guide and confirm no step you took contradicts it (no KV edits, no `legacyGrantResource` changes after rollout).
|