@cloudflare/workers-oauth-provider 1.0.0 → 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 +116 -146
- package/dist/oauth-provider.d.ts +99 -1
- package/dist/oauth-provider.js +364 -8
- package/docs/advanced-configuration.md +1 -1
- package/docs/consent-page.md +138 -0
- package/docs/upstream-sign-in.md +93 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
`@cloudflare/workers-oauth-provider` adds OAuth 2.1 authorization to HTTP APIs and remote MCP servers running on Cloudflare Workers.
|
|
4
4
|
|
|
5
|
+
> **1.0 is out, with a new split API.** The authorization server (`OAuthAuthorizationServer`) and resource server (`OAuthResourceServer`) are now separate classes that can run in one Worker or several; the [quick start](#quick-start) shows both. The single-Worker `OAuthProvider` is still supported. Upgrading from 0.x? Read the [migration guide](docs/migration-1.0.md), or point your coding agent at the skill in [`skills/migrate-to-1.0/`](skills/migrate-to-1.0/SKILL.md), which also ships in the npm package.
|
|
6
|
+
|
|
5
7
|
## Install
|
|
6
8
|
|
|
7
9
|
```sh
|
|
@@ -33,108 +35,25 @@ See [Client registration](#client-registration) for the matching provider option
|
|
|
33
35
|
|
|
34
36
|
## Quick start
|
|
35
37
|
|
|
36
|
-
The
|
|
38
|
+
An MCP deployment has two roles. The **authorization server** signs users in and issues tokens. The **resource server** is your MCP endpoint: it accepts those tokens and checks them with the authorization server over a [Service Binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/). Each is one class.
|
|
39
|
+
|
|
40
|
+
### Authorization server Worker
|
|
37
41
|
|
|
38
42
|
```ts
|
|
39
|
-
import {
|
|
40
|
-
AuthorizationError,
|
|
41
|
-
OAuthProvider,
|
|
42
|
-
type AuthRequest,
|
|
43
|
-
type OAuthHelpers,
|
|
44
|
-
} from '@cloudflare/workers-oauth-provider';
|
|
43
|
+
import { AuthorizationError, OAuthAuthorizationServer, type AuthRequest } from '@cloudflare/workers-oauth-provider';
|
|
45
44
|
import { WorkerEntrypoint } from 'cloudflare:workers';
|
|
46
45
|
|
|
47
|
-
interface AuthProps {
|
|
48
|
-
userId: string;
|
|
49
|
-
displayName: string;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
46
|
interface Env {
|
|
53
47
|
OAUTH_KV: KVNamespace;
|
|
54
|
-
OAUTH_PROVIDER: OAuthHelpers;
|
|
55
48
|
}
|
|
56
49
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
authenticated: true,
|
|
61
|
-
userId: this.ctx.props.userId,
|
|
62
|
-
displayName: this.ctx.props.displayName,
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const defaultHandler: ExportedHandler<Env> = {
|
|
68
|
-
async fetch(request, env) {
|
|
69
|
-
const url = new URL(request.url);
|
|
70
|
-
|
|
71
|
-
if (url.pathname !== '/authorize') {
|
|
72
|
-
return new Response('Not found', { status: 404 });
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// This parses the OAuth parameters and validates the client, redirect URI,
|
|
76
|
-
// response type, resource indicators, and configured PKCE restrictions.
|
|
77
|
-
let oauthRequest: AuthRequest;
|
|
78
|
-
try {
|
|
79
|
-
oauthRequest = await env.OAUTH_PROVIDER.parseAuthRequest(request);
|
|
80
|
-
} catch (error) {
|
|
81
|
-
if (!(error instanceof AuthorizationError)) throw error;
|
|
82
|
-
if (!error.redirectUri) {
|
|
83
|
-
// Unknown clients and invalid redirects must be rendered locally.
|
|
84
|
-
return new Response(error.description, { status: 400 });
|
|
85
|
-
}
|
|
86
|
-
const redirect = new URL(error.redirectUri);
|
|
87
|
-
redirect.searchParams.set('error', error.code);
|
|
88
|
-
redirect.searchParams.set('error_description', error.description);
|
|
89
|
-
if (error.state) redirect.searchParams.set('state', error.state);
|
|
90
|
-
if (error.issuer) redirect.searchParams.set('iss', error.issuer);
|
|
91
|
-
return Response.redirect(redirect, 302);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const client = await env.OAUTH_PROVIDER.lookupClient(oauthRequest.clientId);
|
|
95
|
-
|
|
96
|
-
if (!client) {
|
|
97
|
-
return new Response('Unknown OAuth client', { status: 400 });
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Authenticate the user and obtain consent here. Do not automatically
|
|
101
|
-
// approve a request in production. This example assumes those steps have
|
|
102
|
-
// produced the following user and scope values.
|
|
103
|
-
const user = { id: 'user-123', displayName: 'Ada' };
|
|
104
|
-
const grantedScopes = oauthRequest.scope.filter((scope) => scope === 'mcp:read');
|
|
105
|
-
|
|
106
|
-
const { redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({
|
|
107
|
-
request: oauthRequest,
|
|
108
|
-
userId: user.id,
|
|
109
|
-
metadata: { clientName: client.clientName },
|
|
110
|
-
scope: grantedScopes,
|
|
111
|
-
props: {
|
|
112
|
-
userId: user.id,
|
|
113
|
-
displayName: user.displayName,
|
|
114
|
-
},
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
return Response.redirect(redirectTo, 302);
|
|
118
|
-
},
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
export default new OAuthProvider<Env>({
|
|
122
|
-
apiRoute: '/mcp',
|
|
123
|
-
apiHandler: McpApiHandler,
|
|
124
|
-
defaultHandler,
|
|
125
|
-
|
|
50
|
+
const authorizationServer = new OAuthAuthorizationServer<Env>({
|
|
51
|
+
issuer: 'https://auth.example.com',
|
|
52
|
+
resources: ['https://mcp.example.com/mcp'],
|
|
126
53
|
authorizeEndpoint: '/authorize',
|
|
127
54
|
tokenEndpoint: '/oauth/token',
|
|
128
|
-
|
|
129
55
|
scopesSupported: ['mcp:read'],
|
|
130
56
|
|
|
131
|
-
resourceMetadata: {
|
|
132
|
-
resource: 'https://mcp.example.com/mcp',
|
|
133
|
-
authorization_servers: ['https://mcp.example.com'],
|
|
134
|
-
scopes_supported: ['mcp:read'],
|
|
135
|
-
resource_name: 'Example MCP server',
|
|
136
|
-
},
|
|
137
|
-
|
|
138
57
|
// Preferred for clients with no pre-existing relationship.
|
|
139
58
|
// Also requires global_fetch_strictly_public in wrangler.jsonc.
|
|
140
59
|
clientIdMetadataDocumentEnabled: true,
|
|
@@ -142,65 +61,128 @@ export default new OAuthProvider<Env>({
|
|
|
142
61
|
// Optional compatibility fallback. MCP 2026 deprecates DCR for new clients.
|
|
143
62
|
clientRegistrationEndpoint: '/oauth/register',
|
|
144
63
|
});
|
|
145
|
-
```
|
|
146
64
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
65
|
+
async function authorize(request: Request, env: Env): Promise<Response> {
|
|
66
|
+
const oauth = authorizationServer.getOAuthApi(env);
|
|
67
|
+
|
|
68
|
+
// Parses the OAuth parameters and validates the client, redirect URI,
|
|
69
|
+
// response type, resource indicator, and PKCE.
|
|
70
|
+
let oauthRequest: AuthRequest;
|
|
71
|
+
try {
|
|
72
|
+
oauthRequest = await oauth.parseAuthRequest(request);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (!(error instanceof AuthorizationError)) throw error;
|
|
75
|
+
if (!error.redirectUri) {
|
|
76
|
+
// Unknown clients and invalid redirects must be rendered locally.
|
|
77
|
+
return new Response(error.description, { status: 400 });
|
|
78
|
+
}
|
|
79
|
+
const redirect = new URL(error.redirectUri);
|
|
80
|
+
redirect.searchParams.set('error', error.code);
|
|
81
|
+
redirect.searchParams.set('error_description', error.description);
|
|
82
|
+
if (error.state) redirect.searchParams.set('state', error.state);
|
|
83
|
+
if (error.issuer) redirect.searchParams.set('iss', error.issuer);
|
|
84
|
+
return Response.redirect(redirect.href, 302);
|
|
85
|
+
}
|
|
150
86
|
|
|
151
|
-
|
|
87
|
+
const client = await oauth.lookupClient(oauthRequest.clientId);
|
|
88
|
+
if (!client) return new Response('Unknown OAuth client', { status: 400 });
|
|
89
|
+
|
|
90
|
+
// Authenticate the user and obtain consent here. Never approve automatically
|
|
91
|
+
// in production; this example assumes those steps produced these values.
|
|
92
|
+
const user = { id: 'user-123', displayName: 'Ada' };
|
|
93
|
+
const { redirectTo } = await oauth.completeAuthorization({
|
|
94
|
+
request: oauthRequest,
|
|
95
|
+
userId: user.id,
|
|
96
|
+
metadata: { clientName: client.clientName },
|
|
97
|
+
scope: oauthRequest.scope.filter((scope) => scope === 'mcp:read'),
|
|
98
|
+
props: { userId: user.id, displayName: user.displayName },
|
|
99
|
+
});
|
|
100
|
+
return Response.redirect(redirectTo, 302);
|
|
101
|
+
}
|
|
152
102
|
|
|
153
|
-
|
|
103
|
+
export default class AuthServer extends WorkerEntrypoint<Env> {
|
|
104
|
+
// Your /authorize page; everything else (discovery, token, revocation, registration) is the library's.
|
|
105
|
+
fetch(request: Request) {
|
|
106
|
+
if (new URL(request.url).pathname === '/authorize') return authorize(request, this.env);
|
|
107
|
+
return authorizationServer.fetch(request, this.env, this.ctx);
|
|
108
|
+
}
|
|
154
109
|
|
|
155
|
-
|
|
110
|
+
// Called by resource Workers over their Service Binding.
|
|
111
|
+
validateToken(resource: string, token: string) {
|
|
112
|
+
return authorizationServer.validateToken(resource, token, this.env);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
```
|
|
156
116
|
|
|
157
|
-
|
|
117
|
+
### Resource server Worker
|
|
158
118
|
|
|
159
|
-
|
|
119
|
+
```jsonc
|
|
120
|
+
// wrangler.jsonc
|
|
121
|
+
{
|
|
122
|
+
"services": [{ "binding": "AUTH_SERVER", "service": "auth-server" }],
|
|
123
|
+
}
|
|
124
|
+
```
|
|
160
125
|
|
|
161
126
|
```ts
|
|
162
|
-
|
|
163
|
-
issuer: 'https://auth.example.com',
|
|
164
|
-
resources: ['https://calendar.example.com/mcp'],
|
|
165
|
-
authorizeEndpoint: '/authorize',
|
|
166
|
-
tokenEndpoint: '/oauth/token',
|
|
167
|
-
});
|
|
127
|
+
import { OAuthResourceServer, type AuthorizationServerBinding } from '@cloudflare/workers-oauth-provider';
|
|
168
128
|
|
|
169
|
-
|
|
129
|
+
interface AuthProps {
|
|
130
|
+
userId: string;
|
|
131
|
+
displayName: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface Env {
|
|
135
|
+
AUTH_SERVER: AuthorizationServerBinding<AuthProps>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export default new OAuthResourceServer<Env, AuthProps>({
|
|
170
139
|
resourceMetadata: {
|
|
171
|
-
resource: 'https://
|
|
140
|
+
resource: 'https://mcp.example.com/mcp',
|
|
172
141
|
authorization_servers: ['https://auth.example.com'],
|
|
142
|
+
scopes_supported: ['mcp:read'],
|
|
143
|
+
resource_name: 'Example MCP server',
|
|
144
|
+
},
|
|
145
|
+
validateToken: (env) => env.AUTH_SERVER.validateToken,
|
|
146
|
+
handler: {
|
|
147
|
+
fetch(request, env, ctx) {
|
|
148
|
+
// ctx.props: what completeAuthorization() stored. ctx.auth: the verified token (scope, userId, clientId, …).
|
|
149
|
+
return Response.json({ userId: ctx.props.userId, scope: ctx.auth.scope });
|
|
150
|
+
},
|
|
173
151
|
},
|
|
174
|
-
validateToken: (env) => (resource, token) => authorizationServer.validateToken(resource, token, env),
|
|
175
|
-
handler: { fetch: (_request, _env, ctx) => Response.json({ userId: ctx.props.userId }) },
|
|
176
152
|
});
|
|
177
153
|
```
|
|
178
154
|
|
|
179
|
-
|
|
155
|
+
The resource server publishes its RFC 9728 metadata, answers unauthenticated requests with a Bearer challenge that points at it, validates every token for its own resource only, and passes the handler `ctx.props` and `ctx.auth`. The handler still enforces permissions such as ownership and tenancy; `insufficientScope(ctx.auth, scopes)` builds the MCP `403` when a token lacks a scope an operation needs. The binding is not a URL, so the validator is not reachable from the public internet.
|
|
180
156
|
|
|
181
|
-
|
|
182
|
-
// auth Worker
|
|
183
|
-
export default class AuthServer extends WorkerEntrypoint<Env> {
|
|
184
|
-
fetch(request: Request) {
|
|
185
|
-
return authorizationServer.fetch(request, this.env, this.ctx);
|
|
186
|
-
}
|
|
187
|
-
validateToken(resource: string, token: string) {
|
|
188
|
-
return authorizationServer.validateToken(resource, token, this.env);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
157
|
+
### More resources, or one Worker
|
|
191
158
|
|
|
192
|
-
|
|
193
|
-
|
|
159
|
+
- **Another MCP resource**: add it to `resources` and deploy another `OAuthResourceServer` with the same binding. A token issued for one resource is refused by every other.
|
|
160
|
+
- **Both roles in one Worker**: construct the `OAuthResourceServer` next to the authorization server with a local validator, `validateToken: (env) => (resource, token) => authorizationServer.validateToken(resource, token, env)`, and route to it from your `fetch`.
|
|
161
|
+
|
|
162
|
+
[docs/resource-servers.md](docs/resource-servers.md) covers both, including a three-domain Hono example, and how to accept another issuer's tokens at your own risk.
|
|
163
|
+
|
|
164
|
+
## Single Worker: `OAuthProvider`
|
|
165
|
+
|
|
166
|
+
When one Worker is both the authorization server and its only resource, which was the 0.x shape, `OAuthProvider` combines the two roles. Requests to `apiRoute` are protected and reach `apiHandler` with `ctx.props` and `ctx.auth`; everything else that isn't an OAuth endpoint goes to `defaultHandler`, which owns `/authorize` and reaches the same helpers as `env.OAUTH_PROVIDER`:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
export default new OAuthProvider<Env>({
|
|
170
|
+
apiRoute: '/mcp', // or apiHandlers: { '/mcp': …, '/mcp/admin': … }
|
|
171
|
+
apiHandler: McpApiHandler, // an object with fetch, or a WorkerEntrypoint class
|
|
172
|
+
defaultHandler, // /authorize, using env.OAUTH_PROVIDER.parseAuthRequest() / completeAuthorization()
|
|
173
|
+
authorizeEndpoint: '/authorize',
|
|
174
|
+
tokenEndpoint: '/oauth/token',
|
|
175
|
+
scopesSupported: ['mcp:read'],
|
|
194
176
|
resourceMetadata: {
|
|
195
|
-
resource: 'https://
|
|
196
|
-
authorization_servers: ['https://
|
|
177
|
+
resource: 'https://mcp.example.com/mcp',
|
|
178
|
+
authorization_servers: ['https://mcp.example.com'],
|
|
179
|
+
scopes_supported: ['mcp:read'],
|
|
197
180
|
},
|
|
198
|
-
|
|
199
|
-
handler,
|
|
181
|
+
clientIdMetadataDocumentEnabled: true,
|
|
200
182
|
});
|
|
201
183
|
```
|
|
202
184
|
|
|
203
|
-
|
|
185
|
+
Every protected route must be the canonical `resource` path or a descendant of it; construction rejects anything else.
|
|
204
186
|
|
|
205
187
|
## How MCP authorization discovery works
|
|
206
188
|
|
|
@@ -222,10 +204,10 @@ For an MCP endpoint at `https://mcp.example.com/mcp`:
|
|
|
222
204
|
```
|
|
223
205
|
|
|
224
206
|
4. That document identifies one or more authorization server issuers through `authorization_servers`.
|
|
225
|
-
5. The client fetches RFC 8414 authorization server metadata from the selected issuer. In the
|
|
207
|
+
5. The client fetches RFC 8414 authorization server metadata from the selected issuer. In the quick start that is:
|
|
226
208
|
|
|
227
209
|
```text
|
|
228
|
-
https://
|
|
210
|
+
https://auth.example.com/.well-known/oauth-authorization-server
|
|
229
211
|
```
|
|
230
212
|
|
|
231
213
|
6. The metadata tells the client where to authorize, exchange tokens, and register if registration is enabled.
|
|
@@ -289,6 +271,8 @@ A typical flow has three steps:
|
|
|
289
271
|
2. Authenticate the user, show consent, and decide which scopes to grant.
|
|
290
272
|
3. Call `completeAuthorization()` and redirect to its returned `redirectTo` URL.
|
|
291
273
|
|
|
274
|
+
[docs/consent-page.md](docs/consent-page.md) shows a safe consent page (what it must display, escaping client metadata, Allow and Deny) and which errors to redirect back to the client and which to render.
|
|
275
|
+
|
|
292
276
|
`parseAuthRequest()` throws an exported `AuthorizationError` for expected request validation failures. Its optional `redirectUri` is present only after the client and exact registered redirect URI have been validated. Without it, render the error locally and never redirect. With it, the application can safely construct an OAuth error redirect using the error's `code`, `description`, original `state`, and RFC 9207 `issuer`, as shown in the quick start.
|
|
293
277
|
|
|
294
278
|
`completeAuthorization()` repeats response-type validation before writing a grant or revoking existing grants. Validation errors from reconstructed requests are also typed as `AuthorizationError`, but applications should not construct redirects from untrusted reconstructed values; the redirect context is attached only by `parseAuthRequest()`.
|
|
@@ -422,23 +406,6 @@ Token exchange cannot change the resource. Both the subject-token audience and a
|
|
|
422
406
|
|
|
423
407
|
Path-aware API validation uses path-boundary prefix matching. A canonical audience for `https://example.com/mcp` covers `/mcp` and `/mcp/tools`, but not `/mcp-other`. A canonical trailing slash remains significant.
|
|
424
408
|
|
|
425
|
-
### Upgrading to 1.0
|
|
426
|
-
|
|
427
|
-
The step-by-step guide, including an "Am I affected" checklist and an agent skill (`skills/migrate-to-1.0/`), is [docs/migration-1.0.md](docs/migration-1.0.md). The compatibility rules it relies on:
|
|
428
|
-
|
|
429
|
-
The existing combined `OAuthProvider` configuration has one `resourceMetadata.resource`. That sole resource automatically acts as both the omitted-authorization default and the migration destination for grants created before resource binding, so existing single-resource clients can continue without adding a `resource` parameter.
|
|
430
|
-
|
|
431
|
-
For a multi-resource `OAuthAuthorizationServer`, `defaultResource` and `legacyGrantResource` solve different compatibility problems:
|
|
432
|
-
|
|
433
|
-
- `defaultResource` selects the resource for a new authorization request that omits `resource`.
|
|
434
|
-
- `legacyGrantResource` is the server-controlled migration destination for an old stored grant or access token that has no resource. A client-supplied token-request parameter cannot choose or change this destination. It is deployment policy rather than an issuance-time claim, so changing it re-targets every surviving unbound record; keep it fixed for the migration window.
|
|
435
|
-
|
|
436
|
-
Both values must name a declared resource and are checked at construction. If a multi-resource server omits `legacyGrantResource`, an old unbound grant cannot be migrated safely. A stored grant already bound to a registered resource keeps that resource, and a stored 0.x array that contains the registered resource resolves to it. A grant bound only to unregistered values fails its refresh with `invalid_grant`, which conformant clients answer by starting a new authorization.
|
|
437
|
-
|
|
438
|
-
Previously issued access tokens with no audience keep working until they expire. They are treated as bound to the server-selected migration resource (the sole resource, or `legacyGrantResource`), and refresh binds the grant and returns a bound replacement token. A multi-resource server without `legacyGrantResource` has no safe destination, so it rejects such tokens and their refresh grants must be reauthorized. Multiple resources can share the same authorization server, provider implementation, and KV namespace; separate storage is an optional deployment boundary, not a resource-binding requirement.
|
|
439
|
-
|
|
440
|
-
The 1.0 API removes `resourceMatchOriginOnly`, and a configuration that still sets it fails at construction. Canonical matching with scheme/host case tolerance replaces it.
|
|
441
|
-
|
|
442
409
|
## Scopes and step-up authorization
|
|
443
410
|
|
|
444
411
|
`scopesSupported` is published only in authorization server metadata. Configure each protected resource's `resourceMetadata.scopes_supported` explicitly with the minimal scopes required for its basic functionality and baseline Bearer challenges.
|
|
@@ -454,6 +421,7 @@ The package also supports:
|
|
|
454
421
|
- External API keys and bearer credentials through `resolveExternalToken` as an advanced compatibility feature.
|
|
455
422
|
- Updating encrypted props, token scope, and token lifetimes with `tokenExchangeCallback`.
|
|
456
423
|
- OAuth 2.0 Token Exchange when `allowTokenExchangeGrant` is enabled.
|
|
424
|
+
- Signing users in through another OAuth provider (GitHub, Google, …) with per-client consent and browser-bound `state`. See [docs/upstream-sign-in.md](docs/upstream-sign-in.md).
|
|
457
425
|
- Structured callback errors through the exported `OAuthError` and `ExternalTokenError` classes.
|
|
458
426
|
- Custom error observation or responses through `onError`.
|
|
459
427
|
- Experimental MCP Enterprise-Managed Authorization using ID-JAG assertions.
|
|
@@ -512,6 +480,7 @@ The existing `OAuthProvider` combined configuration uses these options:
|
|
|
512
480
|
| `scopesSupported` | Publish authorization server scopes | Omitted |
|
|
513
481
|
| `resourceMetadata.resource` | Canonical HTTPS resource and token audience | Required |
|
|
514
482
|
| `clientIdMetadataDocumentEnabled` | Enable CIMD lookup and advertisement | `false` |
|
|
483
|
+
| `cookiePrefix` | Prefix for the consent and upstream helpers' cookies (must be `__Host-…`) | `__Host-oauth-` |
|
|
515
484
|
| `allowPlainPKCE` | Permit the legacy plain PKCE method | `false` |
|
|
516
485
|
| `allowImplicitFlow` | Enable implicit token responses | `false` |
|
|
517
486
|
| `disallowPublicClientRegistration` | Reject public clients at DCR | `false` |
|
|
@@ -540,6 +509,7 @@ Consult the exported `OAuthProviderOptions`, `OAuthAuthorizationServerOptions`,
|
|
|
540
509
|
Handlers receive `env.OAUTH_PROVIDER`, which implements `OAuthHelpers`. It can:
|
|
541
510
|
|
|
542
511
|
- Parse authorization requests and complete authorization.
|
|
512
|
+
- Run a consent page and a third-party sign-in redirect safely (`beginConsent()`, `approveConsent()`, `denyConsent()`, `isConsentRemembered()`, `beginUpstream()`, `finishUpstream()`).
|
|
543
513
|
- Look up, create, list, update, and delete clients.
|
|
544
514
|
- List and revoke grants for a user.
|
|
545
515
|
- Inspect internally issued tokens with `unwrapToken()`.
|
package/dist/oauth-provider.d.ts
CHANGED
|
@@ -439,6 +439,50 @@ declare class OAuthResourceServer<Env = Cloudflare.Env, Props = unknown> {
|
|
|
439
439
|
fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response>;
|
|
440
440
|
}
|
|
441
441
|
//#endregion
|
|
442
|
+
//#region src/oauth-consent.d.ts
|
|
443
|
+
/** Remember an approval so the consent page can be skipped for requests it already covers. */
|
|
444
|
+
interface RememberConsentOptions {
|
|
445
|
+
/** HMAC key for the approvals cookie: at least 32 characters, from a Worker secret. */
|
|
446
|
+
secret: string;
|
|
447
|
+
/** How long an approval is remembered. Defaults to 30 days. */
|
|
448
|
+
maxAgeSeconds?: number;
|
|
449
|
+
/**
|
|
450
|
+
* The signed-in user, when you know them before consent (your own sign-in). The approval is then
|
|
451
|
+
* bound to them as well, so another account on the same browser is asked again. Without it an
|
|
452
|
+
* approval belongs to the browser, which suits a proxy server that only learns the user from the
|
|
453
|
+
* third party after consent.
|
|
454
|
+
*/
|
|
455
|
+
subject?: string;
|
|
456
|
+
}
|
|
457
|
+
/** A consent page to render: post `handle` back to {@link approveConsent}; send `headers` with the page. */
|
|
458
|
+
interface ConsentTransaction {
|
|
459
|
+
handle: string;
|
|
460
|
+
headers: Headers;
|
|
461
|
+
}
|
|
462
|
+
/** The approved authorization request, with the cookies to set on the next response. */
|
|
463
|
+
interface ApprovedConsent {
|
|
464
|
+
request: AuthRequest;
|
|
465
|
+
headers: Headers;
|
|
466
|
+
}
|
|
467
|
+
/** Where to send the browser after the user declined, with the cookies to set on that redirect. */
|
|
468
|
+
interface DeniedConsent {
|
|
469
|
+
request: AuthRequest;
|
|
470
|
+
/** The client's redirect URI with `error=access_denied`, its `state`, and `iss` (RFC 9207). */
|
|
471
|
+
redirectTo: string;
|
|
472
|
+
headers: Headers;
|
|
473
|
+
}
|
|
474
|
+
/** The `state` to send to the third-party provider, with the binding cookie to set on the redirect. */
|
|
475
|
+
interface UpstreamTransaction {
|
|
476
|
+
state: string;
|
|
477
|
+
headers: Headers;
|
|
478
|
+
}
|
|
479
|
+
/** The authorization request and data saved by `beginUpstream()`, recovered at the callback. */
|
|
480
|
+
interface ResumedUpstream<Data = unknown> {
|
|
481
|
+
request: AuthRequest;
|
|
482
|
+
data: Data;
|
|
483
|
+
headers: Headers;
|
|
484
|
+
}
|
|
485
|
+
//#endregion
|
|
442
486
|
//#region src/oauth-resource.d.ts
|
|
443
487
|
/** Validate an RFC 3986-safe HTTP(S) resource identifier for RFC 8707. */
|
|
444
488
|
declare function validateResourceUri(uri: string): boolean;
|
|
@@ -879,6 +923,12 @@ interface OAuthProviderOptions<Env = Cloudflare.Env> {
|
|
|
879
923
|
* Defaults to false.
|
|
880
924
|
*/
|
|
881
925
|
clientIdMetadataDocumentEnabled?: boolean;
|
|
926
|
+
/**
|
|
927
|
+
* Prefix for the cookies the consent and upstream helpers set (`<prefix>consent`,
|
|
928
|
+
* `<prefix>upstream`, `<prefix>approvals`). Must start with `__Host-`, which keeps them
|
|
929
|
+
* `Secure`, host-only and `Path=/`. Defaults to `__Host-oauth-`.
|
|
930
|
+
*/
|
|
931
|
+
cookiePrefix?: string;
|
|
882
932
|
/**
|
|
883
933
|
* Metadata for RFC 9728 OAuth 2.0 Protected Resource Metadata.
|
|
884
934
|
* Controls the response served at /.well-known/oauth-protected-resource.
|
|
@@ -956,6 +1006,53 @@ interface OAuthHelpers {
|
|
|
956
1006
|
completeAuthorization(options: CompleteAuthorizationOptions): Promise<{
|
|
957
1007
|
redirectTo: string;
|
|
958
1008
|
}>;
|
|
1009
|
+
/**
|
|
1010
|
+
* Whether an approval remembered by `approveConsent(…, { remember })` covers this request: the
|
|
1011
|
+
* same client, redirect URI and resource, asking for a subset of the approved scopes. Pass the
|
|
1012
|
+
* same `secret`, and the same `subject` if the approval was bound to a user. Don't call it to ask
|
|
1013
|
+
* on every authorization.
|
|
1014
|
+
*/
|
|
1015
|
+
isConsentRemembered(request: Request, authRequest: AuthRequest, remember: Pick<RememberConsentOptions, 'secret' | 'subject'>): Promise<boolean>;
|
|
1016
|
+
/**
|
|
1017
|
+
* Start a consent page for a request. Render your page with `handle` in the form and send
|
|
1018
|
+
* `headers` with it: they bind the handle to this browser and forbid framing the page.
|
|
1019
|
+
*/
|
|
1020
|
+
beginConsent(authRequest: AuthRequest): Promise<ConsentTransaction>;
|
|
1021
|
+
/**
|
|
1022
|
+
* Accept the consent form. `handle` is the value your form posted; the browser's binding cookie
|
|
1023
|
+
* must match it, and it works once. `scope` is what the user approved: fewer or more than the
|
|
1024
|
+
* client requested, each one in `scopesSupported`. `remember` stores the approval in a signed
|
|
1025
|
+
* cookie for `isConsentRemembered()`. Send the returned `headers` on the next response.
|
|
1026
|
+
* @throws AuthorizationError when the handle is missing, unbound, expired, or already used
|
|
1027
|
+
*/
|
|
1028
|
+
approveConsent(request: Request, handle: string, options?: {
|
|
1029
|
+
scope?: string[];
|
|
1030
|
+
remember?: RememberConsentOptions;
|
|
1031
|
+
}): Promise<ApprovedConsent>;
|
|
1032
|
+
/**
|
|
1033
|
+
* Decline the consent form: consumes the handle like `approveConsent()` and returns the redirect
|
|
1034
|
+
* back to the client with `error=access_denied`, its `state` and `iss`. Send `headers` with the
|
|
1035
|
+
* redirect (they include `Location`).
|
|
1036
|
+
* @throws AuthorizationError when the handle is missing, unbound, expired, or already used
|
|
1037
|
+
*/
|
|
1038
|
+
denyConsent(request: Request, handle: string, options?: {
|
|
1039
|
+
description?: string;
|
|
1040
|
+
}): Promise<DeniedConsent>;
|
|
1041
|
+
/**
|
|
1042
|
+
* Save an approved request before redirecting to a third-party provider, and get the `state`
|
|
1043
|
+
* to send it. Call only after consent. `data` is returned at the callback, e.g. a PKCE verifier.
|
|
1044
|
+
* Pass `headers` to add the binding cookie to headers you are already sending.
|
|
1045
|
+
*/
|
|
1046
|
+
beginUpstream(authRequest: AuthRequest, options?: {
|
|
1047
|
+
data?: unknown;
|
|
1048
|
+
headers?: Headers;
|
|
1049
|
+
}): Promise<UpstreamTransaction>;
|
|
1050
|
+
/**
|
|
1051
|
+
* At the third-party provider's callback, recover the approved request and your `data` from the
|
|
1052
|
+
* `state` parameter. The browser's binding cookie must match, and it works once.
|
|
1053
|
+
* @throws AuthorizationError when `state` is missing, unbound, expired, or already used
|
|
1054
|
+
*/
|
|
1055
|
+
finishUpstream<Data = unknown>(request: Request): Promise<ResumedUpstream<Data>>;
|
|
959
1056
|
/**
|
|
960
1057
|
* Creates a new OAuth client
|
|
961
1058
|
* @param clientInfo - Partial client information to create the client with
|
|
@@ -1675,6 +1772,7 @@ interface OAuthErrorOptions {
|
|
|
1675
1772
|
* async function refreshUpstream(props) {
|
|
1676
1773
|
* const res = await fetch(...);
|
|
1677
1774
|
* if (res.status === 401) {
|
|
1775
|
+
* // invalid_grant can never recover: the provider also revokes this grant and its tokens.
|
|
1678
1776
|
* throw new OAuthError('invalid_grant', { description: 'upstream refresh token is invalid' });
|
|
1679
1777
|
* }
|
|
1680
1778
|
* if (res.status === 429) {
|
|
@@ -1796,4 +1894,4 @@ declare function getJwtCryptoAlgorithms(alg: string): {
|
|
|
1796
1894
|
verifyAlgorithm: Parameters<SubtleCrypto['verify']>[0];
|
|
1797
1895
|
};
|
|
1798
1896
|
//#endregion
|
|
1799
|
-
export { AuthRequest, AuthorizationError, type AuthorizationErrorCode, type AuthorizationErrorOptions, AuthorizationServerBinding, CimdFetchError, ClientInfo, ClientRegistrationCallbackOptions, ClientRegistrationCallbackResult, CompleteAuthorizationOptions, type EmaClaimsMapper, type EmaClaimsMapperInput, type EmaClaimsMapperResult, type EmaIdJagClaims, type EmaOptions, type EmaTrustedIssuer, type EmaTrustedIssuerResolver, type EmaTrustedIssuerResolverInput, type EmaValidationError, ExchangeTokenOptions, ExternalTokenError, ExternalTokenErrorOptions, Grant, GrantSummary, GrantType, ListOptions, ListResult, OAuthAuthorizationServer, OAuthAuthorizationServerOptions, OAuthError, OAuthErrorInternal, OAuthErrorOptions, OAuthHelpers, OAuthProtectedResourceMetadata, OAuthProvider, OAuthProvider as default, OAuthProviderOptions, OAuthResourceAuth, OAuthResourceContext, OAuthResourceHandler, OAuthResourceMetadata, OAuthResourceServer, OAuthResourceServerOptions, OAuthResourceTokenValidation, OAuthResourceTokenValidator, OAuthTokenErrorCode, PurgeOptions, PurgeResult, ResolveExternalTokenInput, ResolveExternalTokenResult, Token, TokenBase, TokenExchangeCallbackOptions, TokenExchangeCallbackResult, TokenSummary, ValidatedAccessToken, base64UrlToBytes, getJwtCryptoAlgorithms, getOAuthApi, insufficientScope, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
|
|
1897
|
+
export { type ApprovedConsent, AuthRequest, AuthorizationError, type AuthorizationErrorCode, type AuthorizationErrorOptions, AuthorizationServerBinding, CimdFetchError, ClientInfo, ClientRegistrationCallbackOptions, ClientRegistrationCallbackResult, CompleteAuthorizationOptions, type ConsentTransaction, type DeniedConsent, type EmaClaimsMapper, type EmaClaimsMapperInput, type EmaClaimsMapperResult, type EmaIdJagClaims, type EmaOptions, type EmaTrustedIssuer, type EmaTrustedIssuerResolver, type EmaTrustedIssuerResolverInput, type EmaValidationError, ExchangeTokenOptions, ExternalTokenError, ExternalTokenErrorOptions, Grant, GrantSummary, GrantType, ListOptions, ListResult, OAuthAuthorizationServer, OAuthAuthorizationServerOptions, OAuthError, OAuthErrorInternal, OAuthErrorOptions, OAuthHelpers, OAuthProtectedResourceMetadata, OAuthProvider, OAuthProvider as default, OAuthProviderOptions, OAuthResourceAuth, OAuthResourceContext, OAuthResourceHandler, OAuthResourceMetadata, OAuthResourceServer, OAuthResourceServerOptions, OAuthResourceTokenValidation, OAuthResourceTokenValidator, OAuthTokenErrorCode, PurgeOptions, PurgeResult, type RememberConsentOptions, ResolveExternalTokenInput, ResolveExternalTokenResult, type ResumedUpstream, Token, TokenBase, TokenExchangeCallbackOptions, TokenExchangeCallbackResult, TokenSummary, type UpstreamTransaction, ValidatedAccessToken, base64UrlToBytes, getJwtCryptoAlgorithms, getOAuthApi, insufficientScope, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
|
package/dist/oauth-provider.js
CHANGED
|
@@ -678,7 +678,7 @@ function emaErrorToWire(e) {
|
|
|
678
678
|
* only through the adapters' public interfaces.
|
|
679
679
|
*/
|
|
680
680
|
/** SHA-256 a string and return its hex digest. */
|
|
681
|
-
async function sha256Hex(input) {
|
|
681
|
+
async function sha256Hex$1(input) {
|
|
682
682
|
const data = new TextEncoder().encode(input);
|
|
683
683
|
const buffer = await crypto.subtle.digest("SHA-256", data);
|
|
684
684
|
return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
@@ -701,7 +701,7 @@ const EMA_JTI_KV_PREFIX = "enterprise-jti:";
|
|
|
701
701
|
function createKvJtiStore() {
|
|
702
702
|
return { async markUsed({ issuer, jti, exp, now, env }) {
|
|
703
703
|
const ttl = Math.max(EMA_JTI_MIN_TTL_SECONDS, exp - now);
|
|
704
|
-
const key = `${EMA_JTI_KV_PREFIX}${await sha256Hex(`${issuer}\n${jti}`)}`;
|
|
704
|
+
const key = `${EMA_JTI_KV_PREFIX}${await sha256Hex$1(`${issuer}\n${jti}`)}`;
|
|
705
705
|
if (await env.OAUTH_KV.get(key)) return err({
|
|
706
706
|
reason: "replayed",
|
|
707
707
|
jti
|
|
@@ -1549,6 +1549,317 @@ function appendHeaderValue$1(headers, name, value) {
|
|
|
1549
1549
|
headers.set(name, values.join(", "));
|
|
1550
1550
|
}
|
|
1551
1551
|
|
|
1552
|
+
//#endregion
|
|
1553
|
+
//#region src/oauth-consent.ts
|
|
1554
|
+
/**
|
|
1555
|
+
* Consent and upstream-state primitives for authorization servers that sign users in through a
|
|
1556
|
+
* third-party OAuth provider (an MCP "proxy" server). They implement the protections the MCP
|
|
1557
|
+
* 2026-07-28 security best practices require under Confused Deputy → Mitigation:
|
|
1558
|
+
*
|
|
1559
|
+
* - consent per client before any redirect to the third party, with CSRF protection and anti-framing
|
|
1560
|
+
* headers on the consent page;
|
|
1561
|
+
* - remembered consent, chosen per call, in a signed `__Host-` cookie bound to the client, its redirect
|
|
1562
|
+
* URI and resource (and the user, when the caller knows them), reused only for a subset of the
|
|
1563
|
+
* approved scopes;
|
|
1564
|
+
* - the approved scopes chosen on the consent page, from any the server supports;
|
|
1565
|
+
* - a random `state` stored server-side only after consent, bound to the browser by a `__Host-` cookie,
|
|
1566
|
+
* single-use and short-lived.
|
|
1567
|
+
*
|
|
1568
|
+
* A transaction handle is 256 random bits. KV stores the record under the handle's SHA-256, encrypted
|
|
1569
|
+
* with a key derived from the handle, so KV alone reveals neither the request nor deployer data such as
|
|
1570
|
+
* a PKCE verifier. Each transaction has its own binding cookie, named after its hash and holding the
|
|
1571
|
+
* full hash, so several authorizations can be in flight in one browser (two tabs) without replacing each
|
|
1572
|
+
* other. Single use is `get` then `delete`, which KV cannot make atomic: two concurrent requests from the
|
|
1573
|
+
* same browser with the same handle could both pass; the cookie binding confines that to the browser
|
|
1574
|
+
* that started the transaction.
|
|
1575
|
+
*/
|
|
1576
|
+
const TRANSACTION_TTL_SECONDS = 600;
|
|
1577
|
+
/** Default for the provider's `cookiePrefix` option. */
|
|
1578
|
+
const DEFAULT_COOKIE_PREFIX = "__Host-oauth-";
|
|
1579
|
+
const DEFAULT_REMEMBER_SECONDS = 720 * 60 * 60;
|
|
1580
|
+
const MIN_SECRET_LENGTH = 32;
|
|
1581
|
+
const MAX_APPROVALS_COOKIE_BYTES = 3800;
|
|
1582
|
+
/** Throws a `TypeError` unless the prefix keeps the `__Host-` guarantees the MCP best practices require. */
|
|
1583
|
+
function consentCookies(prefix = DEFAULT_COOKIE_PREFIX) {
|
|
1584
|
+
if (typeof prefix !== "string" || !prefix.startsWith("__Host-") || !/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(prefix)) throw new TypeError("cookiePrefix must start with \"__Host-\" and contain only cookie-name characters");
|
|
1585
|
+
return {
|
|
1586
|
+
consent: `${prefix}consent`,
|
|
1587
|
+
upstream: `${prefix}upstream`,
|
|
1588
|
+
approvals: `${prefix}approvals`
|
|
1589
|
+
};
|
|
1590
|
+
}
|
|
1591
|
+
function validateRememberConsentOptions(options) {
|
|
1592
|
+
if (typeof options !== "object" || options === null) throw new TypeError("remember must be an object with a secret");
|
|
1593
|
+
if (typeof options.secret !== "string" || options.secret.length < MIN_SECRET_LENGTH) throw new TypeError(`remember.secret must be a string of at least ${MIN_SECRET_LENGTH} characters`);
|
|
1594
|
+
const maxAge = options.maxAgeSeconds;
|
|
1595
|
+
if (maxAge !== void 0 && (!Number.isInteger(maxAge) || maxAge <= 0)) throw new TypeError("remember.maxAgeSeconds must be a positive integer");
|
|
1596
|
+
if (options.subject !== void 0 && (typeof options.subject !== "string" || options.subject.length === 0)) throw new TypeError("remember.subject must be a non-empty string");
|
|
1597
|
+
}
|
|
1598
|
+
/** Start a consent transaction for an authorization request that must be shown to the user. */
|
|
1599
|
+
async function beginConsent(kv, cookies, request) {
|
|
1600
|
+
const { handle, hash } = await createTransaction(kv, {
|
|
1601
|
+
kind: "consent",
|
|
1602
|
+
request
|
|
1603
|
+
});
|
|
1604
|
+
return {
|
|
1605
|
+
handle,
|
|
1606
|
+
headers: new Headers({
|
|
1607
|
+
"Set-Cookie": bindingCookie(transactionCookieName(cookies.consent, hash), hash, TRANSACTION_TTL_SECONDS),
|
|
1608
|
+
"Cache-Control": "no-store",
|
|
1609
|
+
"Content-Security-Policy": "frame-ancestors 'none'",
|
|
1610
|
+
"X-Frame-Options": "DENY"
|
|
1611
|
+
})
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* Consume a consent transaction the user approved. `scope`, when given, replaces the requested
|
|
1616
|
+
* scopes: the page may narrow them or offer more, but each must be one the server supports
|
|
1617
|
+
* (`supportedScopes`, from `scopesSupported`). `remember` stores the approval in a signed cookie.
|
|
1618
|
+
*/
|
|
1619
|
+
async function approveConsent(kv, cookies, supportedScopes, request, handle, options = {}) {
|
|
1620
|
+
if (options.remember !== void 0) validateRememberConsentOptions(options.remember);
|
|
1621
|
+
const transaction = await openTransaction(kv, request, cookies.consent, handle, "consent");
|
|
1622
|
+
let approved = transaction.record.request;
|
|
1623
|
+
if (options.scope !== void 0) {
|
|
1624
|
+
const supported = supportedScopes ? new Set(supportedScopes) : void 0;
|
|
1625
|
+
const valid = (scope) => typeof scope === "string" && isValidOAuthScopeToken(scope) && (!supported || supported.has(scope));
|
|
1626
|
+
if (!Array.isArray(options.scope) || !options.scope.every(valid)) throw new AuthorizationError("invalid_scope", { description: "Approved scopes must be ones this server supports" });
|
|
1627
|
+
approved = {
|
|
1628
|
+
...approved,
|
|
1629
|
+
scope: [...new Set(options.scope)]
|
|
1630
|
+
};
|
|
1631
|
+
}
|
|
1632
|
+
await transaction.consume();
|
|
1633
|
+
const headers = new Headers({ "Cache-Control": "no-store" });
|
|
1634
|
+
headers.append("Set-Cookie", clearCookie(transaction.cookieName));
|
|
1635
|
+
if (options.remember) headers.append("Set-Cookie", await rememberApproval(cookies, request, approved, options.remember));
|
|
1636
|
+
return {
|
|
1637
|
+
request: approved,
|
|
1638
|
+
headers
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
/**
|
|
1642
|
+
* Consume a consent transaction the user declined, and build the OAuth error redirect back to the
|
|
1643
|
+
* client: `error=access_denied`, the client's `state`, and `iss`. The redirect URI comes from the
|
|
1644
|
+
* stored request, which `parseAuthRequest()` validated, never from the form.
|
|
1645
|
+
*/
|
|
1646
|
+
async function denyConsent(kv, cookies, request, handle, options = {}) {
|
|
1647
|
+
const transaction = await openTransaction(kv, request, cookies.consent, handle, "consent");
|
|
1648
|
+
await transaction.consume();
|
|
1649
|
+
const record = transaction.record;
|
|
1650
|
+
const redirect = new URL(record.request.redirectUri);
|
|
1651
|
+
redirect.searchParams.set("error", "access_denied");
|
|
1652
|
+
if (options.description) redirect.searchParams.set("error_description", options.description);
|
|
1653
|
+
if (record.request.state) redirect.searchParams.set("state", record.request.state);
|
|
1654
|
+
if (record.request.issuer) redirect.searchParams.set("iss", record.request.issuer);
|
|
1655
|
+
const headers = new Headers({
|
|
1656
|
+
"Cache-Control": "no-store",
|
|
1657
|
+
Location: redirect.href
|
|
1658
|
+
});
|
|
1659
|
+
headers.append("Set-Cookie", clearCookie(transaction.cookieName));
|
|
1660
|
+
return {
|
|
1661
|
+
request: record.request,
|
|
1662
|
+
redirectTo: redirect.href,
|
|
1663
|
+
headers
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
/** Whether a remembered approval covers this request: same client, redirect URI and resource, subset of scopes. */
|
|
1667
|
+
async function isConsentRemembered(cookies, request, authRequest, remember) {
|
|
1668
|
+
validateRememberConsentOptions(remember);
|
|
1669
|
+
const approvals = await readApprovals(cookies, request, remember.secret);
|
|
1670
|
+
const key = await approvalKey(authRequest, remember.subject);
|
|
1671
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1672
|
+
const approval = approvals.find((entry) => entry.k === key && entry.e > now);
|
|
1673
|
+
if (!approval) return false;
|
|
1674
|
+
const approvedScopes = new Set(approval.s);
|
|
1675
|
+
return authRequest.scope.every((scope) => approvedScopes.has(scope));
|
|
1676
|
+
}
|
|
1677
|
+
/**
|
|
1678
|
+
* Save an approved authorization request before redirecting to the third-party provider, and get
|
|
1679
|
+
* the `state` to send it. Call only after consent. Pass `headers` to add the binding cookie to
|
|
1680
|
+
* headers you are already sending, such as `approveConsent()`'s.
|
|
1681
|
+
*/
|
|
1682
|
+
async function beginUpstream(kv, cookies, request, options = {}) {
|
|
1683
|
+
const { handle, hash } = await createTransaction(kv, {
|
|
1684
|
+
kind: "upstream",
|
|
1685
|
+
request,
|
|
1686
|
+
data: options.data
|
|
1687
|
+
});
|
|
1688
|
+
const headers = options.headers ?? new Headers();
|
|
1689
|
+
headers.append("Set-Cookie", bindingCookie(transactionCookieName(cookies.upstream, hash), hash, TRANSACTION_TTL_SECONDS));
|
|
1690
|
+
headers.set("Cache-Control", "no-store");
|
|
1691
|
+
return {
|
|
1692
|
+
state: handle,
|
|
1693
|
+
headers
|
|
1694
|
+
};
|
|
1695
|
+
}
|
|
1696
|
+
/** Recover the authorization request at the third-party provider's callback, from its `state` parameter. */
|
|
1697
|
+
async function finishUpstream(kv, cookies, request) {
|
|
1698
|
+
const state = new URL(request.url).searchParams.get("state");
|
|
1699
|
+
if (!state) throw new AuthorizationError("invalid_request", { description: "Missing state parameter" });
|
|
1700
|
+
const transaction = await openTransaction(kv, request, cookies.upstream, state, "upstream");
|
|
1701
|
+
await transaction.consume();
|
|
1702
|
+
const headers = new Headers({ "Cache-Control": "no-store" });
|
|
1703
|
+
headers.append("Set-Cookie", clearCookie(transaction.cookieName));
|
|
1704
|
+
return {
|
|
1705
|
+
request: transaction.record.request,
|
|
1706
|
+
data: transaction.record.data,
|
|
1707
|
+
headers
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
async function createTransaction(kv, record) {
|
|
1711
|
+
const handle = base64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
1712
|
+
const hash = await sha256Hex(handle);
|
|
1713
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
1714
|
+
const sealed = await crypto.subtle.encrypt({
|
|
1715
|
+
name: "AES-GCM",
|
|
1716
|
+
iv
|
|
1717
|
+
}, await transactionKey(handle), new TextEncoder().encode(JSON.stringify(record)));
|
|
1718
|
+
await kv.put(`transaction:${hash}`, `${base64url(iv)}.${base64url(new Uint8Array(sealed))}`, { expirationTtl: TRANSACTION_TTL_SECONDS });
|
|
1719
|
+
return {
|
|
1720
|
+
handle,
|
|
1721
|
+
hash
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1724
|
+
async function openTransaction(kv, request, cookieBase, handle, kind) {
|
|
1725
|
+
if (typeof handle !== "string" || handle.length === 0) throw new AuthorizationError("invalid_request", { description: "Missing transaction handle" });
|
|
1726
|
+
const hash = await sha256Hex(handle);
|
|
1727
|
+
const cookieName = transactionCookieName(cookieBase, hash);
|
|
1728
|
+
const bound = readCookie(request, cookieName);
|
|
1729
|
+
if (!bound) throw new AuthorizationError("invalid_request", { description: "This authorization was not started in this browser; start again" });
|
|
1730
|
+
if (!timingSafeEqual(hash, bound)) throw new AuthorizationError("invalid_request", { description: "This authorization belongs to a different browser session; start again" });
|
|
1731
|
+
const key = `transaction:${hash}`;
|
|
1732
|
+
const expired = new AuthorizationError("invalid_request", { description: "This authorization expired or was already used; start again" });
|
|
1733
|
+
const stored = await kv.get(key);
|
|
1734
|
+
if (!stored) throw expired;
|
|
1735
|
+
let record;
|
|
1736
|
+
try {
|
|
1737
|
+
const [iv, sealed] = stored.split(".");
|
|
1738
|
+
const plain = await crypto.subtle.decrypt({
|
|
1739
|
+
name: "AES-GCM",
|
|
1740
|
+
iv: fromBase64url(iv)
|
|
1741
|
+
}, await transactionKey(handle), fromBase64url(sealed));
|
|
1742
|
+
record = JSON.parse(new TextDecoder().decode(plain));
|
|
1743
|
+
} catch {
|
|
1744
|
+
throw expired;
|
|
1745
|
+
}
|
|
1746
|
+
if (record.kind !== kind) throw expired;
|
|
1747
|
+
return {
|
|
1748
|
+
record,
|
|
1749
|
+
cookieName,
|
|
1750
|
+
consume: () => kv.delete(key)
|
|
1751
|
+
};
|
|
1752
|
+
}
|
|
1753
|
+
/** Only the holder of the handle can decrypt its record; KV keeps the hash, not the handle. */
|
|
1754
|
+
async function transactionKey(handle) {
|
|
1755
|
+
const material = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`oauth-transaction-key:${handle}`));
|
|
1756
|
+
return crypto.subtle.importKey("raw", material, "AES-GCM", false, ["encrypt", "decrypt"]);
|
|
1757
|
+
}
|
|
1758
|
+
/** One binding cookie per transaction, so concurrent authorizations in one browser don't collide. */
|
|
1759
|
+
function transactionCookieName(base, hash) {
|
|
1760
|
+
return `${base}-${hash.slice(0, 16)}`;
|
|
1761
|
+
}
|
|
1762
|
+
async function rememberApproval(cookies, request, approved, remember) {
|
|
1763
|
+
const maxAge = remember.maxAgeSeconds ?? DEFAULT_REMEMBER_SECONDS;
|
|
1764
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1765
|
+
const key = await approvalKey(approved, remember.subject);
|
|
1766
|
+
const approvals = (await readApprovals(cookies, request, remember.secret)).filter((entry) => entry.e > now && entry.k !== key);
|
|
1767
|
+
approvals.push({
|
|
1768
|
+
k: key,
|
|
1769
|
+
s: approved.scope,
|
|
1770
|
+
e: now + maxAge
|
|
1771
|
+
});
|
|
1772
|
+
let value = await signApprovals(approvals, remember.secret);
|
|
1773
|
+
while (value.length > MAX_APPROVALS_COOKIE_BYTES && approvals.length > 1) {
|
|
1774
|
+
approvals.shift();
|
|
1775
|
+
value = await signApprovals(approvals, remember.secret);
|
|
1776
|
+
}
|
|
1777
|
+
const cookieMaxAge = Math.max(...approvals.map((entry) => entry.e)) - now;
|
|
1778
|
+
return `${cookies.approvals}=${value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=${cookieMaxAge}`;
|
|
1779
|
+
}
|
|
1780
|
+
async function readApprovals(cookies, request, secret) {
|
|
1781
|
+
const value = readCookie(request, cookies.approvals);
|
|
1782
|
+
if (!value) return [];
|
|
1783
|
+
const [payload, signature] = value.split(".");
|
|
1784
|
+
if (!payload || !signature) return [];
|
|
1785
|
+
const key = await hmacKey(secret);
|
|
1786
|
+
let valid = false;
|
|
1787
|
+
try {
|
|
1788
|
+
valid = await crypto.subtle.verify("HMAC", key, fromBase64url(signature), new TextEncoder().encode(payload));
|
|
1789
|
+
} catch {
|
|
1790
|
+
return [];
|
|
1791
|
+
}
|
|
1792
|
+
if (!valid) return [];
|
|
1793
|
+
try {
|
|
1794
|
+
const parsed = JSON.parse(new TextDecoder().decode(fromBase64url(payload)));
|
|
1795
|
+
return Array.isArray(parsed) ? parsed.filter(isApproval) : [];
|
|
1796
|
+
} catch {
|
|
1797
|
+
return [];
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
async function signApprovals(approvals, secret) {
|
|
1801
|
+
const payload = base64url(new TextEncoder().encode(JSON.stringify(approvals)));
|
|
1802
|
+
const signature = await crypto.subtle.sign("HMAC", await hmacKey(secret), new TextEncoder().encode(payload));
|
|
1803
|
+
return `${payload}.${base64url(new Uint8Array(signature))}`;
|
|
1804
|
+
}
|
|
1805
|
+
function isApproval(value) {
|
|
1806
|
+
if (!value || typeof value !== "object") return false;
|
|
1807
|
+
const entry = value;
|
|
1808
|
+
return typeof entry.k === "string" && typeof entry.e === "number" && Array.isArray(entry.s) && entry.s.every((scope) => typeof scope === "string");
|
|
1809
|
+
}
|
|
1810
|
+
/** Approval is bound to the client, where its tokens go, which resource they are for, and the user if known. */
|
|
1811
|
+
async function approvalKey(request, subject) {
|
|
1812
|
+
const material = JSON.stringify([
|
|
1813
|
+
request.clientId,
|
|
1814
|
+
request.redirectUri,
|
|
1815
|
+
request.resource ?? null,
|
|
1816
|
+
subject ?? null
|
|
1817
|
+
]);
|
|
1818
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(material));
|
|
1819
|
+
return base64url(new Uint8Array(digest));
|
|
1820
|
+
}
|
|
1821
|
+
function hmacKey(secret) {
|
|
1822
|
+
return crypto.subtle.importKey("raw", new TextEncoder().encode(secret), {
|
|
1823
|
+
name: "HMAC",
|
|
1824
|
+
hash: "SHA-256"
|
|
1825
|
+
}, false, ["sign", "verify"]);
|
|
1826
|
+
}
|
|
1827
|
+
function bindingCookie(name, value, maxAge) {
|
|
1828
|
+
return `${name}=${value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`;
|
|
1829
|
+
}
|
|
1830
|
+
function clearCookie(name) {
|
|
1831
|
+
return `${name}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`;
|
|
1832
|
+
}
|
|
1833
|
+
function readCookie(request, name) {
|
|
1834
|
+
const header = request.headers.get("Cookie");
|
|
1835
|
+
if (!header) return void 0;
|
|
1836
|
+
for (const part of header.split(";")) {
|
|
1837
|
+
const separator = part.indexOf("=");
|
|
1838
|
+
if (separator === -1) continue;
|
|
1839
|
+
if (part.slice(0, separator).trim() === name) return part.slice(separator + 1).trim();
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
async function sha256Hex(value) {
|
|
1843
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
1844
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
1845
|
+
}
|
|
1846
|
+
function timingSafeEqual(a, b) {
|
|
1847
|
+
if (a.length !== b.length) return false;
|
|
1848
|
+
let difference = 0;
|
|
1849
|
+
for (let index = 0; index < a.length; index++) difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
|
|
1850
|
+
return difference === 0;
|
|
1851
|
+
}
|
|
1852
|
+
function base64url(bytes) {
|
|
1853
|
+
let binary = "";
|
|
1854
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
1855
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1856
|
+
}
|
|
1857
|
+
function fromBase64url(value) {
|
|
1858
|
+
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
1859
|
+
const binary = atob(base64 + "=".repeat((4 - base64.length % 4) % 4));
|
|
1860
|
+
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1552
1863
|
//#endregion
|
|
1553
1864
|
//#region src/oauth-provider.ts
|
|
1554
1865
|
const PROTECTED_RESOURCE_WELL_KNOWN_PREFIX = "/.well-known/oauth-protected-resource";
|
|
@@ -1744,6 +2055,7 @@ var OAuthProviderImpl = class {
|
|
|
1744
2055
|
};
|
|
1745
2056
|
this.validateEndpoint(this.options.authorizeEndpoint, "authorizeEndpoint");
|
|
1746
2057
|
this.validateEndpoint(this.options.tokenEndpoint, "tokenEndpoint");
|
|
2058
|
+
this.consentCookies = consentCookies(this.options.cookiePrefix);
|
|
1747
2059
|
if (this.options.clientRegistrationEndpoint) this.validateEndpoint(this.options.clientRegistrationEndpoint, "clientRegistrationEndpoint");
|
|
1748
2060
|
if (roleBased) this.validateAuthorizationServerRouteIsolation();
|
|
1749
2061
|
this.resourceServers = configuredResourceServers.map(({ resourceMetadata, resolveExternalToken }) => {
|
|
@@ -2460,6 +2772,24 @@ var OAuthProviderImpl = class {
|
|
|
2460
2772
|
* token issuance or `tokenExchangeCallback`. Anything else is re-thrown so
|
|
2461
2773
|
* unexpected failures still surface as 500s.
|
|
2462
2774
|
*/
|
|
2775
|
+
/**
|
|
2776
|
+
* Run `tokenExchangeCallback`. An `invalid_grant` it throws means the grant can never work again
|
|
2777
|
+
* (RFC 6749 §5.2: invalid, expired, or revoked; transient failures are `temporarily_unavailable`),
|
|
2778
|
+
* so the grant it ran for is revoked, with its tokens, before the error answers. A failed
|
|
2779
|
+
* revocation is logged and the callback's error still answers the request.
|
|
2780
|
+
*/
|
|
2781
|
+
async callTokenExchangeCallback(options, env) {
|
|
2782
|
+
try {
|
|
2783
|
+
return await Promise.resolve(this.options.tokenExchangeCallback(options));
|
|
2784
|
+
} catch (error) {
|
|
2785
|
+
if (error instanceof OAuthError && error.code === "invalid_grant") try {
|
|
2786
|
+
await this.createOAuthHelpers(env).revokeGrant(options.grantId, options.userId);
|
|
2787
|
+
} catch (revokeError) {
|
|
2788
|
+
console.warn(`Failed to revoke grant ${options.grantId} after tokenExchangeCallback answered invalid_grant:`, revokeError);
|
|
2789
|
+
}
|
|
2790
|
+
throw error;
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2463
2793
|
createOAuthErrorResponse(error) {
|
|
2464
2794
|
if (!(error instanceof OAuthError)) return void 0;
|
|
2465
2795
|
return this.createErrorResponse(error.code, error.options, error.options.internal ?? {
|
|
@@ -2613,7 +2943,7 @@ var OAuthProviderImpl = class {
|
|
|
2613
2943
|
resource: audience,
|
|
2614
2944
|
props: decryptedProps
|
|
2615
2945
|
};
|
|
2616
|
-
const callbackResult = await
|
|
2946
|
+
const callbackResult = await this.callTokenExchangeCallback(callbackOptions, env);
|
|
2617
2947
|
if (callbackResult) {
|
|
2618
2948
|
if (callbackResult.newProps) {
|
|
2619
2949
|
grantProps = callbackResult.newProps;
|
|
@@ -2760,7 +3090,7 @@ var OAuthProviderImpl = class {
|
|
|
2760
3090
|
resource: audience,
|
|
2761
3091
|
props: decryptedProps
|
|
2762
3092
|
};
|
|
2763
|
-
const callbackResult = await
|
|
3093
|
+
const callbackResult = await this.callTokenExchangeCallback(callbackOptions, env);
|
|
2764
3094
|
if (callbackResult) {
|
|
2765
3095
|
if (callbackResult.newProps) {
|
|
2766
3096
|
grantProps = callbackResult.newProps;
|
|
@@ -2946,7 +3276,7 @@ var OAuthProviderImpl = class {
|
|
|
2946
3276
|
resource: newAudience,
|
|
2947
3277
|
props: decryptedProps
|
|
2948
3278
|
};
|
|
2949
|
-
const callbackResult = await
|
|
3279
|
+
const callbackResult = await this.callTokenExchangeCallback(callbackOptions, env);
|
|
2950
3280
|
if (crossClientExchange && callbackResult?.allowCrossClientExchange !== true) throw crossClientRejection();
|
|
2951
3281
|
if (callbackResult) {
|
|
2952
3282
|
let accessTokenProps = decryptedProps;
|
|
@@ -4037,6 +4367,7 @@ var OAuthProviderImpl = class {
|
|
|
4037
4367
|
* async function refreshUpstream(props) {
|
|
4038
4368
|
* const res = await fetch(...);
|
|
4039
4369
|
* if (res.status === 401) {
|
|
4370
|
+
* // invalid_grant can never recover: the provider also revokes this grant and its tokens.
|
|
4040
4371
|
* throw new OAuthError('invalid_grant', { description: 'upstream refresh token is invalid' });
|
|
4041
4372
|
* }
|
|
4042
4373
|
* if (res.status === 429) {
|
|
@@ -4496,11 +4827,11 @@ const WRAPPING_KEY_HMAC_KEY = new Uint8Array([
|
|
|
4496
4827
|
*/
|
|
4497
4828
|
async function deriveKeyFromToken(tokenStr) {
|
|
4498
4829
|
const encoder = new TextEncoder();
|
|
4499
|
-
const hmacKey = await crypto.subtle.importKey("raw", WRAPPING_KEY_HMAC_KEY, {
|
|
4830
|
+
const hmacKey$1 = await crypto.subtle.importKey("raw", WRAPPING_KEY_HMAC_KEY, {
|
|
4500
4831
|
name: "HMAC",
|
|
4501
4832
|
hash: "SHA-256"
|
|
4502
4833
|
}, false, ["sign"]);
|
|
4503
|
-
const hmacResult = await crypto.subtle.sign("HMAC", hmacKey, encoder.encode(tokenStr));
|
|
4834
|
+
const hmacResult = await crypto.subtle.sign("HMAC", hmacKey$1, encoder.encode(tokenStr));
|
|
4504
4835
|
return await crypto.subtle.importKey("raw", hmacResult, { name: "AES-KW" }, false, ["wrapKey", "unwrapKey"]);
|
|
4505
4836
|
}
|
|
4506
4837
|
/**
|
|
@@ -4849,7 +5180,8 @@ var OAuthHelpersImpl = class {
|
|
|
4849
5180
|
};
|
|
4850
5181
|
if (!isPublicClient && secretToStore) updatedClient.clientSecret = secretToStore;
|
|
4851
5182
|
else delete updatedClient.clientSecret;
|
|
4852
|
-
await this.
|
|
5183
|
+
if (client.registrationExpiresAt === void 0) await this.env.OAUTH_KV.put(`client:${updatedClient.clientId}`, JSON.stringify(updatedClient));
|
|
5184
|
+
else await this.provider.putRegisteredClient(this.env, updatedClient, Math.floor(Date.now() / 1e3));
|
|
4853
5185
|
const response = toPublicClientInfo(updatedClient);
|
|
4854
5186
|
if (!isPublicClient && originalSecret) response.clientSecret = originalSecret;
|
|
4855
5187
|
return response;
|
|
@@ -4917,6 +5249,30 @@ var OAuthHelpersImpl = class {
|
|
|
4917
5249
|
* @param userId - The ID of the user who owns the grant
|
|
4918
5250
|
* @returns A Promise resolving when the revocation is confirmed.
|
|
4919
5251
|
*/
|
|
5252
|
+
/** {@inheritDoc OAuthHelpers.isConsentRemembered} */
|
|
5253
|
+
isConsentRemembered(request, authRequest, remember) {
|
|
5254
|
+
return isConsentRemembered(this.provider.consentCookies, request, authRequest, remember);
|
|
5255
|
+
}
|
|
5256
|
+
/** {@inheritDoc OAuthHelpers.beginConsent} */
|
|
5257
|
+
beginConsent(authRequest) {
|
|
5258
|
+
return beginConsent(this.env.OAUTH_KV, this.provider.consentCookies, authRequest);
|
|
5259
|
+
}
|
|
5260
|
+
/** {@inheritDoc OAuthHelpers.approveConsent} */
|
|
5261
|
+
approveConsent(request, handle, options) {
|
|
5262
|
+
return approveConsent(this.env.OAUTH_KV, this.provider.consentCookies, this.provider.options.scopesSupported, request, handle, options);
|
|
5263
|
+
}
|
|
5264
|
+
/** {@inheritDoc OAuthHelpers.denyConsent} */
|
|
5265
|
+
denyConsent(request, handle, options) {
|
|
5266
|
+
return denyConsent(this.env.OAUTH_KV, this.provider.consentCookies, request, handle, options);
|
|
5267
|
+
}
|
|
5268
|
+
/** {@inheritDoc OAuthHelpers.beginUpstream} */
|
|
5269
|
+
beginUpstream(authRequest, options) {
|
|
5270
|
+
return beginUpstream(this.env.OAUTH_KV, this.provider.consentCookies, authRequest, options);
|
|
5271
|
+
}
|
|
5272
|
+
/** {@inheritDoc OAuthHelpers.finishUpstream} */
|
|
5273
|
+
finishUpstream(request) {
|
|
5274
|
+
return finishUpstream(this.env.OAUTH_KV, this.provider.consentCookies, request);
|
|
5275
|
+
}
|
|
4920
5276
|
async revokeGrant(grantId, userId) {
|
|
4921
5277
|
const grantKey = `grant:${userId}:${grantId}`;
|
|
4922
5278
|
const tokenPrefix = `token:${userId}:${grantId}:`;
|
|
@@ -220,7 +220,7 @@ onError({ code, internal }) {
|
|
|
220
220
|
|
|
221
221
|
`OAuthError(code, options)` supports token-endpoint errors from `tokenExchangeCallback`. `ExternalTokenError(code, options)` supports protected-resource errors from `resolveExternalToken`, including `requiredScopes` for an `insufficient_scope` challenge.
|
|
222
222
|
|
|
223
|
-
Both classes accept a public `description`, `statusCode`, and response `headers`. Only the exported class intended for that callback boundary is converted. Other errors remain unexpected failures. An `OAuthError` may also set `options.internal` to give `onError` its own category and reason; without one it arrives as `{ category: 'token-exchange-callback', reason: 'callback_error', detail: error }`.
|
|
223
|
+
Both classes accept a public `description`, `statusCode`, and response `headers`. Only the exported class intended for that callback boundary is converted. Other errors remain unexpected failures. An `OAuthError` may also set `options.internal` to give `onError` its own category and reason; without one it arrives as `{ category: 'token-exchange-callback', reason: 'callback_error', detail: error }`. An `invalid_grant` thrown from `tokenExchangeCallback` also revokes the grant the callback ran for, with its tokens: it means the grant can never work again. Use `temporarily_unavailable` for transient upstream failures (see [upstream-sign-in.md](upstream-sign-in.md#when-the-third-party-revokes-access)).
|
|
224
224
|
|
|
225
225
|
## Token and client lifetimes
|
|
226
226
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# Building a consent page
|
|
2
|
+
|
|
3
|
+
Your `authorizeEndpoint` is your page: the library validates the request, and you sign the user in and ask whether this client may act for them. The consent helpers (`beginConsent()`, `approveConsent()`, `denyConsent()`, `isConsentRemembered()`) make that page safe to build. A server that signs users in through another provider also needs [upstream-sign-in.md](upstream-sign-in.md).
|
|
4
|
+
|
|
5
|
+
## What the page must show
|
|
6
|
+
|
|
7
|
+
From the MCP authorization spec and security best practices:
|
|
8
|
+
|
|
9
|
+
- **The client's name**, and **the scopes** being granted.
|
|
10
|
+
- **The redirect URI's hostname** (MUST): where the tokens will go.
|
|
11
|
+
- **A warning when that hostname is `localhost`** (SHOULD). A CIMD client's name comes from its metadata document, but anyone can present that document and listen on a local port, so the name alone doesn't prove which app is asking.
|
|
12
|
+
- **A CIMD client's domain**, prominently. Its `client_id` is a URL on a domain the client controls; a DCR client's name is self-asserted.
|
|
13
|
+
- **No framing**, and a form that can't be forged: `beginConsent()` returns the headers and the browser-bound handle that do this.
|
|
14
|
+
|
|
15
|
+
## Escape everything that came from the client
|
|
16
|
+
|
|
17
|
+
`clientName`, `clientUri`, `logoUri` and the scope strings come from dynamic registration or a CIMD document, so an attacker chooses them. Rendered without escaping, they're script running on your authorization origin, next to your users' sessions.
|
|
18
|
+
|
|
19
|
+
## A minimal page
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import type { AuthRequest, ClientInfo } from '@cloudflare/workers-oauth-provider';
|
|
23
|
+
|
|
24
|
+
const escape = (value: string) => value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
|
|
25
|
+
|
|
26
|
+
function consentPage(client: ClientInfo, request: AuthRequest, handle: string): string {
|
|
27
|
+
const name = escape(client.clientName ?? client.clientId);
|
|
28
|
+
const redirectHost = new URL(request.redirectUri).hostname;
|
|
29
|
+
const local = /^(localhost|127(\.\d{1,3}){3}|\[::1\])$/.test(redirectHost);
|
|
30
|
+
const origin = client.clientId.startsWith('https://')
|
|
31
|
+
? `Published by <strong>${escape(new URL(client.clientId).hostname)}</strong>.`
|
|
32
|
+
: 'This app registered itself; its name is not verified.';
|
|
33
|
+
const scopes = request.scope
|
|
34
|
+
.map(
|
|
35
|
+
(scope) => `<label><input type="checkbox" name="scope" value="${escape(scope)}" checked> ${escape(scope)}</label>`
|
|
36
|
+
)
|
|
37
|
+
.join('<br>');
|
|
38
|
+
return `<!doctype html>
|
|
39
|
+
<meta charset="utf-8">
|
|
40
|
+
<title>Authorize ${name}</title>
|
|
41
|
+
<h1>Allow ${name} to access your account?</h1>
|
|
42
|
+
<p>${origin} Access will be sent to <strong>${escape(redirectHost)}</strong>.</p>
|
|
43
|
+
${local ? '<p><strong>This sends access to an app on your computer.</strong> Continue only if you just started signing in from it.</p>' : ''}
|
|
44
|
+
<form method="post">
|
|
45
|
+
<input type="hidden" name="handle" value="${escape(handle)}">
|
|
46
|
+
${scopes}
|
|
47
|
+
<p><button name="decision" value="approve">Allow</button> <button name="decision" value="deny">Deny</button></p>
|
|
48
|
+
</form>`;
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Showing it, approving, declining
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
const oauth = authorizationServer.getOAuthApi(env); // or env.OAUTH_PROVIDER with OAuthProvider
|
|
56
|
+
|
|
57
|
+
// GET /authorize (after signing the user in with your own session)
|
|
58
|
+
const request = await oauth.parseAuthRequest(req);
|
|
59
|
+
const client = await oauth.lookupClient(request.clientId);
|
|
60
|
+
const consent = await oauth.beginConsent(request);
|
|
61
|
+
consent.headers.set('Content-Type', 'text/html; charset=utf-8');
|
|
62
|
+
return new Response(consentPage(client!, request, consent.handle), { headers: consent.headers });
|
|
63
|
+
|
|
64
|
+
// POST /authorize
|
|
65
|
+
const form = await req.formData();
|
|
66
|
+
const handle = String(form.get('handle'));
|
|
67
|
+
if (form.get('decision') !== 'approve') {
|
|
68
|
+
const denied = await oauth.denyConsent(req, handle); // redirect to the client: access_denied, state, iss
|
|
69
|
+
return new Response(null, { status: 302, headers: denied.headers });
|
|
70
|
+
}
|
|
71
|
+
const approved = await oauth.approveConsent(req, handle, { scope: form.getAll('scope').map(String) });
|
|
72
|
+
const { redirectTo } = await oauth.completeAuthorization({
|
|
73
|
+
request: approved.request, // from storage, not from the form
|
|
74
|
+
userId: session.userId,
|
|
75
|
+
metadata: {},
|
|
76
|
+
scope: approved.request.scope,
|
|
77
|
+
props: { userId: session.userId },
|
|
78
|
+
});
|
|
79
|
+
approved.headers.set('Location', redirectTo);
|
|
80
|
+
return new Response(null, { status: 302, headers: approved.headers });
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The authorization request is kept server-side between the two requests; the form carries only the handle, which works once, for ten minutes, in the browser that opened the page. `scope` is what the user ticked: fewer or more than the client requested, each in `scopesSupported`.
|
|
84
|
+
|
|
85
|
+
## Errors: redirect or render?
|
|
86
|
+
|
|
87
|
+
A redirect back to the client is only safe once the client and its exact redirect URI are validated. Everything else is shown on your page.
|
|
88
|
+
|
|
89
|
+
| Where it fails | What to do |
|
|
90
|
+
| ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
|
91
|
+
| `parseAuthRequest()` throws `AuthorizationError` **with** `redirectUri` | Redirect to it with `error`, `error_description`, `state` and `iss` from the error (see the quick start) |
|
|
92
|
+
| `parseAuthRequest()` throws `AuthorizationError` **without** `redirectUri` | Render locally. Never redirect: the client or redirect URI isn't trusted |
|
|
93
|
+
| `parseAuthRequest()` / `lookupClient()` throw `CimdFetchError` | Render locally: the client's metadata document couldn't be fetched (`error.reason`, `error.detail` for your logs) |
|
|
94
|
+
| `approveConsent()`, `denyConsent()`, `finishUpstream()` throw `AuthorizationError` | Render locally: the page expired, was used, or was opened in another browser, or the scopes aren't supported. Offer to start again |
|
|
95
|
+
| Any helper throws something else (`TypeError`, a KV failure) | A bug or an outage, not the user's doing: let it surface as a 500 |
|
|
96
|
+
| The user clicks Deny | `denyConsent()`, then send its redirect |
|
|
97
|
+
| A third-party provider returns `error=` to your callback | `finishUpstream()`, then redirect to the client with `access_denied` ([upstream-sign-in.md](upstream-sign-in.md)) |
|
|
98
|
+
| Token endpoint errors | The library answers them; observe them with `onError` |
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
try {
|
|
102
|
+
// …the handlers above
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (error instanceof AuthorizationError && error.redirectUri) {
|
|
105
|
+
const redirect = new URL(error.redirectUri);
|
|
106
|
+
redirect.searchParams.set('error', error.code);
|
|
107
|
+
redirect.searchParams.set('error_description', error.description);
|
|
108
|
+
if (error.state) redirect.searchParams.set('state', error.state);
|
|
109
|
+
if (error.issuer) redirect.searchParams.set('iss', error.issuer);
|
|
110
|
+
return Response.redirect(redirect.href, 302);
|
|
111
|
+
}
|
|
112
|
+
if (error instanceof AuthorizationError || error instanceof CimdFetchError) {
|
|
113
|
+
const message = error instanceof AuthorizationError ? error.description : 'This app could not be verified.';
|
|
114
|
+
return new Response(escape(message), { status: 400, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
|
|
115
|
+
}
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Remembering consent
|
|
121
|
+
|
|
122
|
+
By default the page appears on every authorization, which also lets users re-authorize with different scopes. To skip it for clients a user already approved, pass `remember` when approving and check before showing the page:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
const remember = { secret: env.CONSENT_SECRET, subject: session.userId }; // secret: 32+ chars, from a Worker secret
|
|
126
|
+
|
|
127
|
+
if (await oauth.isConsentRemembered(req, request, remember)) {
|
|
128
|
+
// skip the page: complete the authorization (or start the third-party sign-in) directly
|
|
129
|
+
}
|
|
130
|
+
// …when approving:
|
|
131
|
+
await oauth.approveConsent(req, handle, { scope, remember }); // maxAgeSeconds defaults to 30 days
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Approvals live in a signed `__Host-` cookie, bound to the client ID, its redirect URI and the resource, and they cover only the scopes that were approved: asking for more brings the page back. Pass `subject` (the signed-in user) whenever you know it, so another account on the same browser is asked again. Without it an approval belongs to the browser, which is what a proxy server gets, since it learns the user from the third party only after consent.
|
|
135
|
+
|
|
136
|
+
## Cookie names
|
|
137
|
+
|
|
138
|
+
Each consent page and each third-party redirect gets its own short-lived cookie, `__Host-oauth-consent-…` or `__Host-oauth-upstream-…` (so two tabs can authorize at once), and remembered approvals live in `__Host-oauth-approvals`. Change the prefix with the `cookiePrefix` option if those collide with yours; it must start with `__Host-`.
|
|
@@ -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
|
+
```
|