@gydr-ai/mcp-oauth 0.0.0-stage → 0.1.2
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/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +717 -2
- package/dist/index.js +47 -0
- package/dist/index.js.map +7 -0
- package/dist/types/constants/defaults.d.ts +1 -0
- package/dist/types/constants/error-pages.d.ts +21 -0
- package/dist/types/constants/oidc.d.ts +1 -0
- package/dist/types/constants/protocol.d.ts +1 -0
- package/dist/types/core/authenticate.d.ts +1 -0
- package/dist/types/core/client-form.d.ts +1 -0
- package/dist/types/core/client-metadata.d.ts +11 -0
- package/dist/types/core/clients.d.ts +1 -0
- package/dist/types/core/code-exchange.d.ts +1 -0
- package/dist/types/core/consent-page.d.ts +1 -0
- package/dist/types/core/context.d.ts +1 -0
- package/dist/types/core/grant-context.d.ts +1 -0
- package/dist/types/core/grant-summary.d.ts +1 -0
- package/dist/types/core/grants.d.ts +1 -0
- package/dist/types/core/metadata.d.ts +1 -0
- package/dist/types/core/pages.d.ts +1 -0
- package/dist/types/core/policy.d.ts +7 -0
- package/dist/types/core/refresh-exchange.d.ts +1 -0
- package/dist/types/core/resolve-clients.d.ts +1 -0
- package/dist/types/core/resolve-options.d.ts +1 -0
- package/dist/types/core/resolve-parts.d.ts +1 -0
- package/dist/types/core/router.d.ts +1 -0
- package/dist/types/core/server.d.ts +50 -0
- package/dist/types/core/subject.d.ts +1 -0
- package/dist/types/core/token-policy.d.ts +1 -0
- package/dist/types/core/upstream-tokens.d.ts +1 -0
- package/dist/types/crypto/aes-gcm-sealer.d.ts +27 -0
- package/dist/types/errors/body-too-large-error.d.ts +1 -0
- package/dist/types/errors/client-metadata-error.d.ts +1 -0
- package/dist/types/errors/endpoint-refusal.d.ts +1 -0
- package/dist/types/errors/grant-inactive-error.d.ts +26 -0
- package/dist/types/errors/identity-provider-error.d.ts +30 -0
- package/dist/types/errors/mcp-auth-config-error.d.ts +23 -0
- package/dist/types/errors/sealer-error.d.ts +24 -0
- package/dist/types/errors/upstream-token-error.d.ts +28 -0
- package/dist/types/handlers/authorize.d.ts +1 -0
- package/dist/types/handlers/callback.d.ts +1 -0
- package/dist/types/handlers/consent.d.ts +1 -0
- package/dist/types/handlers/register.d.ts +1 -0
- package/dist/types/handlers/revoke.d.ts +1 -0
- package/dist/types/handlers/token.d.ts +1 -0
- package/dist/types/helpers/body.d.ts +1 -0
- package/dist/types/helpers/client-fields.d.ts +1 -0
- package/dist/types/helpers/client-ip.d.ts +1 -0
- package/dist/types/helpers/cookies.d.ts +1 -0
- package/dist/types/helpers/freeze.d.ts +1 -0
- package/dist/types/helpers/guards.d.ts +1 -0
- package/dist/types/helpers/html-document.d.ts +1 -0
- package/dist/types/helpers/json.d.ts +1 -0
- package/dist/types/helpers/locales.d.ts +1 -0
- package/dist/types/helpers/params.d.ts +1 -0
- package/dist/types/helpers/redirect-uris.d.ts +12 -0
- package/dist/types/helpers/resource.d.ts +1 -0
- package/dist/types/helpers/responses.d.ts +1 -0
- package/dist/types/helpers/scopes.d.ts +1 -0
- package/dist/types/helpers/text.d.ts +32 -0
- package/dist/types/helpers/time.d.ts +1 -0
- package/dist/types/helpers/tokens.d.ts +1 -0
- package/dist/types/index.d.ts +30 -0
- package/dist/types/interfaces/auth.d.ts +151 -0
- package/dist/types/interfaces/consent.d.ts +164 -0
- package/dist/types/interfaces/context.d.ts +1 -0
- package/dist/types/interfaces/events.d.ts +166 -0
- package/dist/types/interfaces/identity-provider.d.ts +251 -0
- package/dist/types/interfaces/memory-store.d.ts +27 -0
- package/dist/types/interfaces/oidc.d.ts +55 -0
- package/dist/types/interfaces/options.d.ts +575 -0
- package/dist/types/interfaces/pages.d.ts +98 -0
- package/dist/types/interfaces/policy.d.ts +355 -0
- package/dist/types/interfaces/records.d.ts +383 -0
- package/dist/types/interfaces/routes.d.ts +1 -0
- package/dist/types/interfaces/sealer.d.ts +67 -0
- package/dist/types/interfaces/store.d.ts +195 -0
- package/dist/types/interfaces/token-response.d.ts +1 -0
- package/dist/types/providers/oidc-documents.d.ts +1 -0
- package/dist/types/providers/oidc-provider.d.ts +31 -0
- package/dist/types/stores/client-collection.d.ts +1 -0
- package/dist/types/stores/collection.d.ts +1 -0
- package/dist/types/stores/memory-store.d.ts +22 -0
- package/package.json +71 -4
package/README.md
CHANGED
|
@@ -1,3 +1,718 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">@gydr-ai/mcp-oauth</h1>
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="center">
|
|
4
|
+
<strong>OAuth 2.1 for remote MCP servers, in front of the identity provider you already run.</strong><br />
|
|
5
|
+
Its own audience-bound tokens, consent on every connection, and no token passthrough.
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
<p align="center">
|
|
9
|
+
<a href="https://www.npmjs.com/package/@gydr-ai/mcp-oauth"><img alt="npm" src="https://img.shields.io/npm/v/@gydr-ai/mcp-oauth" /></a>
|
|
10
|
+
<a href="https://github.com/gydr-ai/mcp-oauth/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/gydr-ai/mcp-oauth/actions/workflows/ci.yml/badge.svg" /></a>
|
|
11
|
+
<a href="https://scorecard.dev/viewer/?uri=github.com/gydr-ai/mcp-oauth"><img alt="OpenSSF Scorecard" src="https://api.scorecard.dev/projects/github.com/gydr-ai/mcp-oauth/badge" /></a>
|
|
12
|
+
<img alt="MCP 2026-07-28" src="https://img.shields.io/badge/MCP-2026--07--28-6f42c1" />
|
|
13
|
+
<img alt="OAuth 2.1 draft-17" src="https://img.shields.io/badge/OAuth_2.1-draft--17-0969da" />
|
|
14
|
+
<img alt="CIMD draft-03" src="https://img.shields.io/badge/CIMD-draft--03-0969da" />
|
|
15
|
+
<img alt="Node.js 22.12+" src="https://img.shields.io/node/v/@gydr-ai/mcp-oauth" />
|
|
16
|
+
<a href="LICENSE"><img alt="License: Apache-2.0" src="https://img.shields.io/badge/license-Apache--2.0-blue" /></a>
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
<p align="center">
|
|
20
|
+
<sub>Built and maintained by <a href="https://gydr.ai"><strong>Gydr</strong></a></sub>
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
A remote [Model Context Protocol](https://modelcontextprotocol.io) server
|
|
26
|
+
must run an OAuth authorization server that MCP clients (Claude, ChatGPT,
|
|
27
|
+
Cursor and others) can discover, register with and sign in through. You
|
|
28
|
+
already have an identity provider; you do not want to hand its tokens to
|
|
29
|
+
every MCP client that asks.
|
|
30
|
+
|
|
31
|
+
`@gydr-ai/mcp-oauth` is that authorization server. People sign in at **your
|
|
32
|
+
OpenID Connect provider**, approve the connection on a consent screen, and
|
|
33
|
+
the MCP client receives **opaque tokens that only your MCP server accepts**.
|
|
34
|
+
The provider's tokens never leave your server.
|
|
35
|
+
|
|
36
|
+
## Highlights
|
|
37
|
+
|
|
38
|
+
- **Spec-complete for MCP.** Protected-resource and authorization-server
|
|
39
|
+
metadata, Client ID Metadata Documents, Dynamic Client Registration
|
|
40
|
+
(deprecated, still on by default), PKCE, resource indicators and issuer
|
|
41
|
+
identification: everything the
|
|
42
|
+
[MCP Authorization specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization)
|
|
43
|
+
asks of a server.
|
|
44
|
+
- **No token passthrough.** Clients get tokens bound to your MCP endpoint.
|
|
45
|
+
The person's upstream refresh token stays on the server, encrypted per
|
|
46
|
+
grant, and your code calls `getUpstreamAccessToken()` when it needs one.
|
|
47
|
+
- **Consent every time.** Each authorization names the client and where the
|
|
48
|
+
result goes, which closes the "confused deputy" gap the MCP
|
|
49
|
+
[security best practices](https://modelcontextprotocol.io/specification/2026-07-28/basic/security_best_practices)
|
|
50
|
+
describe. It cannot be switched off.
|
|
51
|
+
- **Hardened by default.** Exact redirect matching, browser-bound flows,
|
|
52
|
+
single-use codes, refresh-token rotation with replay detection, strict page
|
|
53
|
+
headers, and SSRF-safe metadata downloads.
|
|
54
|
+
- **Fits any stack.** Web `Request` in, `Response` out: Hono, Next.js,
|
|
55
|
+
Fastify, Express, plain Node. Any OIDC provider, any database with an
|
|
56
|
+
atomic write, any key-management service.
|
|
57
|
+
- **Safely extensible.** Policy hooks that can only refuse, enrichment hooks
|
|
58
|
+
that cannot touch the protocol, and every limit configurable.
|
|
59
|
+
|
|
60
|
+
## Contents
|
|
61
|
+
|
|
62
|
+
- [How it works](#how-it-works)
|
|
63
|
+
- [Install](#install)
|
|
64
|
+
- [Quick start](#quick-start)
|
|
65
|
+
- [Guides](#guides)
|
|
66
|
+
- [API](#api)
|
|
67
|
+
- [Security model](#security-model)
|
|
68
|
+
- [Specification compliance](#specification-compliance)
|
|
69
|
+
- [Compatibility](#compatibility)
|
|
70
|
+
- [Security](#security)
|
|
71
|
+
- [About Gydr](#about-gydr)
|
|
72
|
+
|
|
73
|
+
## How it works
|
|
74
|
+
|
|
75
|
+
```mermaid
|
|
76
|
+
sequenceDiagram
|
|
77
|
+
autonumber
|
|
78
|
+
participant C as MCP client
|
|
79
|
+
participant B as Browser
|
|
80
|
+
participant S as Your server + mcp-oauth
|
|
81
|
+
participant I as Identity provider
|
|
82
|
+
|
|
83
|
+
C->>S: MCP request without a token
|
|
84
|
+
S-->>C: 401, WWW-Authenticate points at resource metadata
|
|
85
|
+
C->>S: discover metadata, register (or use a metadata-document client_id)
|
|
86
|
+
C->>B: open /authorize (PKCE, resource, state)
|
|
87
|
+
B->>S: GET /authorize
|
|
88
|
+
S-->>B: redirect to the IdP, plus a browser-binding cookie
|
|
89
|
+
B->>I: sign in
|
|
90
|
+
I-->>B: back to /callback
|
|
91
|
+
S->>I: exchange the code, verify the ID token
|
|
92
|
+
S-->>B: consent screen
|
|
93
|
+
B->>S: approve
|
|
94
|
+
S-->>B: redirect to the client with a one-time code
|
|
95
|
+
C->>S: POST /token (code + PKCE verifier)
|
|
96
|
+
S-->>C: opaque access and refresh tokens for your MCP endpoint
|
|
97
|
+
C->>S: MCP request with Authorization: Bearer
|
|
98
|
+
S->>I: renew the upstream token when your code needs it (server side only)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Install
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
npm install @gydr-ai/mcp-oauth
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Node.js 22.12 or later. ES modules only. Runtime dependencies:
|
|
108
|
+
[`jose`](https://github.com/panva/jose) and
|
|
109
|
+
[`@gydr-ai/safe-fetch`](https://github.com/gydr-ai/safe-fetch).
|
|
110
|
+
|
|
111
|
+
## Quick start
|
|
112
|
+
|
|
113
|
+
With [Hono](https://hono.dev):
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
import { serve } from "@hono/node-server"
|
|
117
|
+
import { Hono } from "hono"
|
|
118
|
+
import {
|
|
119
|
+
createAesGcmSealer,
|
|
120
|
+
createMcpAuth,
|
|
121
|
+
createMemoryStore,
|
|
122
|
+
createOidcProvider,
|
|
123
|
+
} from "@gydr-ai/mcp-oauth"
|
|
124
|
+
|
|
125
|
+
const ORIGIN = "https://mcp.example.com"
|
|
126
|
+
|
|
127
|
+
const auth = createMcpAuth({
|
|
128
|
+
issuer: ORIGIN,
|
|
129
|
+
resource: `${ORIGIN}/mcp`,
|
|
130
|
+
identityProvider: createOidcProvider({
|
|
131
|
+
issuer: process.env.OIDC_ISSUER ?? "",
|
|
132
|
+
clientId: process.env.OIDC_CLIENT_ID ?? "",
|
|
133
|
+
clientSecret: process.env.OIDC_CLIENT_SECRET ?? "",
|
|
134
|
+
scopes: ["openid", "email", "offline_access"],
|
|
135
|
+
redirectUri: `${ORIGIN}/callback`, // register exactly this at your IdP
|
|
136
|
+
}),
|
|
137
|
+
store: createMemoryStore(), // development only, see the production checklist
|
|
138
|
+
sealer: createAesGcmSealer({
|
|
139
|
+
keys: { k1: Buffer.from(process.env.SEALING_KEY ?? "", "base64") },
|
|
140
|
+
currentKid: "k1",
|
|
141
|
+
}),
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
const app = new Hono()
|
|
145
|
+
|
|
146
|
+
// OAuth routes and metadata; anything else falls through.
|
|
147
|
+
app.use("*", async (c, next) => (await auth.handle(c.req.raw)) ?? next())
|
|
148
|
+
|
|
149
|
+
app.all("/mcp", async (c) => {
|
|
150
|
+
const result = await auth.authenticate(c.req.raw)
|
|
151
|
+
|
|
152
|
+
if (!result.ok) return result.response // 401 with the right challenge
|
|
153
|
+
|
|
154
|
+
// result.subject, result.scopes, result.clientId, result.context
|
|
155
|
+
// await result.getUpstreamAccessToken() to call your own APIs as the person
|
|
156
|
+
return handleMcp(c.req.raw, result)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
serve({ fetch: app.fetch, port: 3000 })
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
A sealing key is 32 random bytes:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
node -e "console.log(require('node:crypto').randomBytes(32).toString('base64'))"
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Guides
|
|
169
|
+
|
|
170
|
+
### Node `http` and Express
|
|
171
|
+
|
|
172
|
+
Two small conversions, Node built-ins only. Build URLs from your configured
|
|
173
|
+
public origin, never from the incoming `Host` header.
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
import type { IncomingMessage, ServerResponse } from "node:http"
|
|
177
|
+
import { Readable } from "node:stream"
|
|
178
|
+
|
|
179
|
+
const PUBLIC_ORIGIN = "https://mcp.example.com"
|
|
180
|
+
|
|
181
|
+
export function toWebRequest(req: IncomingMessage): Request {
|
|
182
|
+
const headers = new Headers()
|
|
183
|
+
|
|
184
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
185
|
+
if (Array.isArray(value)) for (const v of value) headers.append(name, v)
|
|
186
|
+
else if (value !== undefined) headers.set(name, value)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const hasBody = req.method !== "GET" && req.method !== "HEAD"
|
|
190
|
+
|
|
191
|
+
return new Request(new URL(req.url ?? "/", PUBLIC_ORIGIN), {
|
|
192
|
+
method: req.method,
|
|
193
|
+
headers,
|
|
194
|
+
body: hasBody ? Readable.toWeb(req) : null,
|
|
195
|
+
// @ts-expect-error Node needs this for a streamed request body
|
|
196
|
+
duplex: hasBody ? "half" : undefined,
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function sendWebResponse(
|
|
201
|
+
res: ServerResponse,
|
|
202
|
+
response: Response,
|
|
203
|
+
): Promise<void> {
|
|
204
|
+
res.statusCode = response.status
|
|
205
|
+
response.headers.forEach((value, name) => {
|
|
206
|
+
if (name !== "set-cookie") res.setHeader(name, value)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
const cookies = response.headers.getSetCookie()
|
|
210
|
+
|
|
211
|
+
if (cookies.length > 0) res.setHeader("set-cookie", cookies)
|
|
212
|
+
|
|
213
|
+
res.end(Buffer.from(await response.arrayBuffer()))
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Express (mount before any body parser, so the body stream is untouched):
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
import express from "express"
|
|
221
|
+
|
|
222
|
+
const app = express()
|
|
223
|
+
|
|
224
|
+
app.use(async (req, res, next) => {
|
|
225
|
+
try {
|
|
226
|
+
const response = await auth.handle(toWebRequest(req))
|
|
227
|
+
|
|
228
|
+
if (response) await sendWebResponse(res, response)
|
|
229
|
+
else next()
|
|
230
|
+
} catch (error) {
|
|
231
|
+
next(error)
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
app.all("/mcp", async (req, res, next) => {
|
|
236
|
+
try {
|
|
237
|
+
const request = toWebRequest(req)
|
|
238
|
+
const result = await auth.authenticate(request)
|
|
239
|
+
|
|
240
|
+
if (!result.ok) return await sendWebResponse(res, result.response)
|
|
241
|
+
|
|
242
|
+
await sendWebResponse(res, await handleMcp(request, result))
|
|
243
|
+
} catch (error) {
|
|
244
|
+
next(error)
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
app.listen(3000)
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Plain Node `http` works the same way: call `auth.handle`, then
|
|
252
|
+
`auth.authenticate` on your MCP path.
|
|
253
|
+
|
|
254
|
+
### Extending safely
|
|
255
|
+
|
|
256
|
+
Every extension point can **tighten**, **enrich** or **observe**. None can
|
|
257
|
+
switch off PKCE, exact redirect matching, consent, the browser binding and
|
|
258
|
+
form token, single-use codes, refresh replay detection, audience binding,
|
|
259
|
+
opaque tokens, or the escaping and Content-Security-Policy on pages.
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
const auth = createMcpAuth({
|
|
263
|
+
// ...issuer, resource, identityProvider, store, sealer
|
|
264
|
+
scopes: ["mcp:read", "mcp:write"],
|
|
265
|
+
scopeDescriptions: {
|
|
266
|
+
"mcp:read": "Read your documents",
|
|
267
|
+
"mcp:write": "Create and edit documents",
|
|
268
|
+
},
|
|
269
|
+
clients: [
|
|
270
|
+
{
|
|
271
|
+
clientId: "team-desktop",
|
|
272
|
+
clientName: "Team Desktop",
|
|
273
|
+
redirectUris: ["http://127.0.0.1/callback"],
|
|
274
|
+
grantTypes: ["authorization_code", "refresh_token"],
|
|
275
|
+
},
|
|
276
|
+
],
|
|
277
|
+
authorizeClient: ({ client, scopes }) =>
|
|
278
|
+
client.kind !== "dynamic" || !scopes.includes("mcp:write"),
|
|
279
|
+
allowSubject: ({ claims }) => claims.email_verified === true,
|
|
280
|
+
mapSubject: ({ subject, claims }) => ({
|
|
281
|
+
subject,
|
|
282
|
+
displayName: typeof claims.email === "string" ? claims.email : undefined,
|
|
283
|
+
}),
|
|
284
|
+
getGrantContext: async ({ subject }) => ({ tenant: await tenantOf(subject) }),
|
|
285
|
+
})
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
**Policy hooks** run only after every protocol check has passed. Only a
|
|
289
|
+
literal `true` (or `{ allow: true }`) continues; `false`, any other value, a
|
|
290
|
+
rejection or a throw refuses. A refusal may name a reason,
|
|
291
|
+
`{ allow: false, reason: "no_seat" }` (1–64 characters of
|
|
292
|
+
`A-Z a-z 0-9 _ . : -`), which reaches your error page as `policyReason`.
|
|
293
|
+
Every hook receives deeply frozen copies of the data it inspects (the
|
|
294
|
+
client, scopes, metadata, claims and grant), so it can read validated state
|
|
295
|
+
but cannot change what the server goes on to use.
|
|
296
|
+
|
|
297
|
+
| Hook | Called | On refusal |
|
|
298
|
+
| ------------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
|
299
|
+
| `authorizeClient` | `/authorize`, after full validation | 403 page (`reason: "access_denied"`) |
|
|
300
|
+
| `allowRegistration` | `/register`, after the metadata checks | `403 invalid_client_metadata`, nothing stored |
|
|
301
|
+
| `allowSubject` | after sign-in and `mapSubject` | 403 page, upstream refresh token revoked |
|
|
302
|
+
| `allowClientMetadataUrl` | before a metadata document is fetched | the client is unknown, nothing is fetched |
|
|
303
|
+
| `beforeGrant` | after approval, before the grant | `access_denied` to the client, upstream revoked |
|
|
304
|
+
| `beforeTokenIssue` | before a code or refresh token is used | `invalid_grant`; a throw is `503 temporarily_unavailable`, and the code or refresh token stays usable |
|
|
305
|
+
|
|
306
|
+
**Enrichment hooks**
|
|
307
|
+
|
|
308
|
+
- `mapSubject({ subject, displayName, claims })` returns
|
|
309
|
+
`{ subject, displayName? }`, for example to key grants on your own user
|
|
310
|
+
id. Each returned field is read once. The subject (mapped or not) must be
|
|
311
|
+
a non-empty string of at most 512 characters, without surrounding
|
|
312
|
+
whitespace, in Unicode NFC form and free of invisible or formatting
|
|
313
|
+
characters; otherwise sign-in stops and the upstream token is revoked. A
|
|
314
|
+
display name with such characters, or longer than
|
|
315
|
+
`limits.clientNameLength`, is dropped.
|
|
316
|
+
- `getGrantContext(grant)` returns JSON data exposed as
|
|
317
|
+
`AuthResult.context` (a tenant id, a plan), within
|
|
318
|
+
`limits.grantContextBytes`. It cannot change the subject, scopes or
|
|
319
|
+
audience, and tokens never carry it. If it throws, or returns data that is
|
|
320
|
+
too large or not serializable, `authenticate` returns `{ ok: false }` with a
|
|
321
|
+
generic 500 response and emits `grant_context_failed`; the hook's error
|
|
322
|
+
text is never returned or reported.
|
|
323
|
+
|
|
324
|
+
**Consent extensions**
|
|
325
|
+
|
|
326
|
+
Your consent page can ask for more than yes or no (a workspace, a plan),
|
|
327
|
+
and the answer can travel with the grant.
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
const auth = createMcpAuth({
|
|
331
|
+
// ...
|
|
332
|
+
// Data for your consent page, as `details` in ConsentPageContext.
|
|
333
|
+
getConsentDetails: async ({ subject }) => ({
|
|
334
|
+
workspaces: await workspacesOf(subject),
|
|
335
|
+
}),
|
|
336
|
+
// Your page's extra fields, read only after the form token and the
|
|
337
|
+
// browser cookie were verified.
|
|
338
|
+
readConsentFields: ({ form, details }) =>
|
|
339
|
+
form.workspace === undefined
|
|
340
|
+
? { ok: false, fieldErrors: { workspace: "Choose a workspace" } }
|
|
341
|
+
: { ok: true, grantData: { workspace: form.workspace } },
|
|
342
|
+
beforeGrant: async ({ subject }) =>
|
|
343
|
+
(await hasSeat(subject)) ? true : { allow: false, reason: "no_seat" },
|
|
344
|
+
afterGrant: ({ grant }) => recordConnection(grant.grantId, grant.subject),
|
|
345
|
+
getGrantContext: ({ data }) => ({ workspace: data }),
|
|
346
|
+
})
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
- `getConsentDetails` runs at the callback; its JSON result
|
|
350
|
+
(`limits.consentDetailsBytes`) reaches the page and `readConsentFields`.
|
|
351
|
+
A throw ends the sign-in with an error page and revokes the upstream
|
|
352
|
+
token.
|
|
353
|
+
- `readConsentFields` never sees the package's own fields. `fieldErrors`
|
|
354
|
+
shows the page again (status 400, with `fieldErrors` and `submitted`)
|
|
355
|
+
and uses nothing up; `grantData` (`limits.grantDataBytes`) is stored as
|
|
356
|
+
`GrantRecord.data`, passed to `getGrantContext`, and can be replaced
|
|
357
|
+
later with `auth.updateGrantData(grantId, data)`. A throw or an unusable
|
|
358
|
+
result ends the flow with a 500 page and revokes the upstream token.
|
|
359
|
+
- `afterGrant` is awaited before the client is answered. If it throws, the
|
|
360
|
+
grant is deleted, the upstream token revoked and the client receives
|
|
361
|
+
`error=server_error`.
|
|
362
|
+
- `scriptHashes: ["sha256-…"]` lets a custom consent page run those inline
|
|
363
|
+
scripts (`script-src`); only `sha256-` hashes are accepted.
|
|
364
|
+
- A consent submission repeated from the same browser within
|
|
365
|
+
`consentReplaySeconds` (default 10, at most 60) receives the same
|
|
366
|
+
redirect again, when the store implements the optional
|
|
367
|
+
`putConsentOutcome` / `getConsentOutcome`; otherwise a repeat sees an
|
|
368
|
+
error page. The remembered redirect is stored encrypted.
|
|
369
|
+
|
|
370
|
+
**Pages**
|
|
371
|
+
|
|
372
|
+
`renderConsentPage` and `renderErrorPage` receive deeply frozen contexts
|
|
373
|
+
with `locales` (from `Accept-Language`, most preferred first). Error pages
|
|
374
|
+
carry a stable `reason` code (`client_unknown`, `invalid_scope`,
|
|
375
|
+
`subject_not_permitted`, …) to switch on for your own wording, plus
|
|
376
|
+
`policyReason` when a hook gave one.
|
|
377
|
+
|
|
378
|
+
**Upstream tokens and connections**
|
|
379
|
+
|
|
380
|
+
- `IdentityProvider.refresh(refreshToken, { grantId, subject })` tells a
|
|
381
|
+
custom provider which grant a renewal is for.
|
|
382
|
+
- `result.getUpstreamAccessToken({ forceRefresh: true })` and
|
|
383
|
+
`auth.renewUpstream(grantId)` discard the cached upstream access token
|
|
384
|
+
(in memory and on the grant) and renew it, for example after the
|
|
385
|
+
provider answered 401. Concurrent renewals of one grant share one call.
|
|
386
|
+
- `auth.revokeGrantsForSubject(subject)` ends every grant of one person
|
|
387
|
+
(for an account closure) when the store implements the optional
|
|
388
|
+
`listGrantsBySubject`; without it, it throws `McpAuthConfigError`.
|
|
389
|
+
- The `grant_revoked` event carries a frozen copy of the grant (never its
|
|
390
|
+
encrypted upstream tokens) and is emitted before the grant is deleted.
|
|
391
|
+
|
|
392
|
+
**Checked configuration**
|
|
393
|
+
|
|
394
|
+
- `clients`: pre-registered public clients, held to the same rules as
|
|
395
|
+
registration. An `https` client id is refused (that form means a metadata
|
|
396
|
+
document).
|
|
397
|
+
- `scopeDescriptions`: consent-screen text per supported scope, escaped.
|
|
398
|
+
|
|
399
|
+
There is deliberately no hook to replace the random source or to add claims
|
|
400
|
+
to tokens: either would let configuration weaken token secrecy.
|
|
401
|
+
|
|
402
|
+
### Implementing a store
|
|
403
|
+
|
|
404
|
+
You implement `OAuthStore` on your own database. It only ever receives
|
|
405
|
+
SHA-256 hashes of tokens and codes, and upstream tokens already encrypted.
|
|
406
|
+
|
|
407
|
+
Four operations decide races and **must be atomic across every process that
|
|
408
|
+
shares the store**: a conditional write, a transaction, a compare-and-set,
|
|
409
|
+
or a delete that returns the row, never a read followed by a write.
|
|
410
|
+
|
|
411
|
+
| Operation | Contract | Why |
|
|
412
|
+
| --------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------- |
|
|
413
|
+
| `consumePendingAuthorization(id)` | One caller receives the record, which is deleted. | A sign-in completes once. |
|
|
414
|
+
| `consumeConsentRequest(id)` | The same; should still return a held record that has expired. | One approval creates one grant. |
|
|
415
|
+
| `consumeAuthorizationCode(hash)` | Exactly one caller sees `firstUse: true`; later callers see `false`. | Codes are single-use and a repeat is detected. |
|
|
416
|
+
| `useRefreshToken(hash, now)` | Sets `usedAt` if unset; exactly one caller sees no `previousUsedAt`. | Refresh replay detection cannot be raced. |
|
|
417
|
+
|
|
418
|
+
Expired records read as absent and may be deleted at any time, for example
|
|
419
|
+
by a database TTL (`getConsentRequest` may also return an expired one).
|
|
420
|
+
`deleteGrant` must make `getGrant` return nothing, and should resolve with
|
|
421
|
+
the deleted record so the last stored upstream token is the one revoked.
|
|
422
|
+
`getAuthorizationCode` reads a code, consumed or not, without using it up:
|
|
423
|
+
the token endpoint checks the client, redirect URI, resource and PKCE
|
|
424
|
+
verifier against it first.
|
|
425
|
+
|
|
426
|
+
Three methods are optional: `putConsentOutcome` / `getConsentOutcome`
|
|
427
|
+
(answering a repeated consent submission) and `listGrantsBySubject`
|
|
428
|
+
(`revokeGrantsForSubject`).
|
|
429
|
+
|
|
430
|
+
Good fits: PostgreSQL or MySQL (`UPDATE … WHERE used_at IS NULL`,
|
|
431
|
+
`DELETE … RETURNING`), Redis (`GETDEL`, Lua), MongoDB (`findOneAndUpdate`),
|
|
432
|
+
DynamoDB (conditional writes) and Cloudflare D1.
|
|
433
|
+
|
|
434
|
+
**Client lifetime.** Anyone can call `/register`, so a store should never
|
|
435
|
+
evict a client that holds a live grant, should delete a dynamic client
|
|
436
|
+
without one after an idle period (28 days in the memory store), and should
|
|
437
|
+
evict the least recently used grant-less dynamic client first when full.
|
|
438
|
+
|
|
439
|
+
### Production checklist
|
|
440
|
+
|
|
441
|
+
- [ ] **A persistent, shared store** that honours the atomicity contract.
|
|
442
|
+
- [ ] **Managed keys.** Sealing keys come from a secret manager, KMS or HSM.
|
|
443
|
+
Rotate by adding a key id, making it `currentKid`, and keeping the old
|
|
444
|
+
key until no grant sealed with it remains (`lifetimes.grant`).
|
|
445
|
+
- [ ] **HTTPS** for the issuer and resource.
|
|
446
|
+
- [ ] **Rate limiting** through `rateLimit` and `getClientIp` (or
|
|
447
|
+
`trustProxy` behind exactly one proxy you control). **Without
|
|
448
|
+
`getClientIp` or `trustProxy`, every request shares the single key
|
|
449
|
+
`"unknown"`**, so one busy client can exhaust the limit for everyone.
|
|
450
|
+
Without `rateLimit`, `POST /register` is open to flooding. A starting
|
|
451
|
+
point: 10 registrations per address per hour, 30 authorization starts
|
|
452
|
+
per address per minute.
|
|
453
|
+
- [ ] **Alerts** from `onEvent` on `refresh_token_replayed`,
|
|
454
|
+
`authorization_code_replayed`, and bursts of `flow_binding_mismatch` or
|
|
455
|
+
`client_metadata_refused`.
|
|
456
|
+
- [ ] **Identity provider**: register `<issuer origin><callback path>` as the
|
|
457
|
+
only redirect URI of a confidential client, request a refresh token
|
|
458
|
+
(often `offline_access`), and enable refresh rotation if offered.
|
|
459
|
+
- [ ] **Connection management**: let people see and remove their
|
|
460
|
+
connections, and call `revokeGrant` when they do or when an account
|
|
461
|
+
closes.
|
|
462
|
+
|
|
463
|
+
## API
|
|
464
|
+
|
|
465
|
+
### `createMcpAuth(options)`
|
|
466
|
+
|
|
467
|
+
| Member | Purpose |
|
|
468
|
+
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
469
|
+
| `handle(request)` | Serves the OAuth routes and metadata. Resolves `undefined` for any other path. |
|
|
470
|
+
| `authenticate(request)` | Checks the bearer token. `{ ok: true, subject, grantId, clientId, scopes, context, getUpstreamAccessToken() }` or `{ ok: false, response }`. |
|
|
471
|
+
| `unauthorized(request, error?)` | A ready 401 challenge (403 for `insufficient_scope`), e.g. when a tool needs a scope the token lacks. |
|
|
472
|
+
| `revokeGrant(grantId)` | Ends a grant: its tokens stop working and the upstream refresh token is revoked when possible. |
|
|
473
|
+
| `revokeGrantsForSubject(subject)` | Ends every grant of one person; needs the store's `listGrantsBySubject`. Resolves the count. |
|
|
474
|
+
| `renewUpstream(grantId)` | Discards the cached upstream access token and renews it at the identity provider. |
|
|
475
|
+
| `updateGrantData(grantId, data)` | Replaces `GrantRecord.data` (JSON, within `limits.grantDataBytes`). Resolves `false` for an unknown grant. |
|
|
476
|
+
|
|
477
|
+
### Options
|
|
478
|
+
|
|
479
|
+
| Option | Default | Meaning |
|
|
480
|
+
| --------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
481
|
+
| `issuer` | required | HTTPS URL; endpoints live under its path. |
|
|
482
|
+
| `resource` | required | Canonical URL of your MCP endpoint, the audience of every token. |
|
|
483
|
+
| `identityProvider` | required | `createOidcProvider(…)` or your own `IdentityProvider`. |
|
|
484
|
+
| `store` | required | Your `OAuthStore`. |
|
|
485
|
+
| `sealer` | required | `createAesGcmSealer(…)` or your own `SecretSealer`. |
|
|
486
|
+
| `scopes` / `defaultScopes` | `["mcp"]` / all | Scopes offered, and those granted when none are asked for. |
|
|
487
|
+
| `scopeDescriptions` | none | Consent-screen text per scope. |
|
|
488
|
+
| `resourceName` | none | RFC 9728 `resource_name`. |
|
|
489
|
+
| `clients` | none | Pre-registered public clients. |
|
|
490
|
+
| `dynamicClientRegistration` | `true` | Serve `POST /register`. Deprecated by MCP 2026-07-28 in favour of metadata documents; `dynamicClientRegistration: false` turns it off. |
|
|
491
|
+
| `clientIdMetadataDocuments` | `true` | Accept HTTPS URL client ids. |
|
|
492
|
+
| `fetchClientMetadata(url, { signal })` | SSRF-safe fetcher | Replace how metadata documents are downloaded. Must stay SSRF-safe. |
|
|
493
|
+
| `lifetimes` | see below | Token and flow lifetimes, in seconds. |
|
|
494
|
+
| `limits` | see below | Sizes and counts. |
|
|
495
|
+
| `refreshReuseGraceSeconds` | `10` | Retry window (0–300) for a just-used refresh token. `0` treats any reuse as replay. |
|
|
496
|
+
| `consentReplaySeconds` | `10` | Window (0–60) in which a repeated consent submission from the same browser gets the same redirect. |
|
|
497
|
+
| `scriptHashes` | none | `sha256-…` hashes of inline scripts a custom consent page may run. |
|
|
498
|
+
| `paths` | `/authorize`, `/callback`, `/consent`, `/token`, `/register`, `/revoke` | Route paths under the issuer path: one leading `/`, no `//`, `\`, dot segments or whitespace, distinct, and never a metadata path or the resource path. |
|
|
499
|
+
| `cookiePrefix` | `__Host-mcp-auth-` | Must start with `__Host-`. |
|
|
500
|
+
| `rateLimit(key, scope)` | none | Resolve `true` to continue; anything else (including a throw) answers 429. |
|
|
501
|
+
| `getClientIp(request)` / `trustProxy` | none / `false` | Rate-limit key. `trustProxy` uses the right-most `X-Forwarded-For` entry. |
|
|
502
|
+
| `renderConsentPage` / `renderErrorPage` | built in | Your own pages; see `ConsentPageContext`. |
|
|
503
|
+
| `onEvent(event)` | none | Typed audit events; never contain secrets. |
|
|
504
|
+
| policy and enrichment hooks | none | See [Extending safely](#extending-safely). |
|
|
505
|
+
| `allowInsecureLocalhost` | `false` | Allow an `http://localhost` issuer and resource in development. |
|
|
506
|
+
| `now` | `Date.now` | Clock, for tests. A non-finite value fails the request instead of disabling expiry checks. |
|
|
507
|
+
|
|
508
|
+
<details>
|
|
509
|
+
<summary><strong><code>lifetimes</code></strong> (seconds)</summary>
|
|
510
|
+
|
|
511
|
+
| Key | Default | Allowed |
|
|
512
|
+
| ----------------------- | ---------------------------------------------------------------------------- | ------------ |
|
|
513
|
+
| `accessToken` | 1,800 (30 minutes) | 1–86,400 |
|
|
514
|
+
| `refreshToken` | 1,209,600 (14 days, renewed on every rotation) | 1–34,560,000 |
|
|
515
|
+
| `grant` | 7,776,000 (90 days, absolute; then consent again) | 1–34,560,000 |
|
|
516
|
+
| `authorizationCode` | 60 | 1–600 |
|
|
517
|
+
| `pendingAuthorization` | 900 (sign-in plus consent) | 1–3,600 |
|
|
518
|
+
| `clientMetadataCache` | 1,800 (`0` disables the cache); a document's own `Cache-Control` shortens it | 0–86,400 |
|
|
519
|
+
| `clientMetadataFailure` | 60 (back-off for a failed metadata URL; `0` disables) | 0–3,600 |
|
|
520
|
+
|
|
521
|
+
</details>
|
|
522
|
+
|
|
523
|
+
<details>
|
|
524
|
+
<summary><strong><code>limits</code></strong></summary>
|
|
525
|
+
|
|
526
|
+
| Key | Default |
|
|
527
|
+
| ------------------------- | --------------------------------------------- |
|
|
528
|
+
| `clientNameLength` | 128 characters |
|
|
529
|
+
| `redirectUris` | 8 per client |
|
|
530
|
+
| `redirectUriLength` | 2,048 characters |
|
|
531
|
+
| `stateLength` | 2,048 characters |
|
|
532
|
+
| `scopeLength` | 1,024 characters |
|
|
533
|
+
| `registrationBytes` | 16 KiB |
|
|
534
|
+
| `formBytes` | 32 KiB (token, revocation and consent bodies) |
|
|
535
|
+
| `clientMetadataBytes` | 5 KiB (CIMD draft-03 § 10.11) |
|
|
536
|
+
| `clientMetadataTimeoutMs` | 4,000 |
|
|
537
|
+
| `clientMetadataFailures` | 10,000 failed URLs remembered |
|
|
538
|
+
| `grantContextBytes` | 4,096 (serialized `getGrantContext` result) |
|
|
539
|
+
| `consentDetailsBytes` | 8,192 (serialized `getConsentDetails` result) |
|
|
540
|
+
| `grantDataBytes` | 4,096 (serialized grant data) |
|
|
541
|
+
|
|
542
|
+
</details>
|
|
543
|
+
|
|
544
|
+
### Routes
|
|
545
|
+
|
|
546
|
+
| Route | Purpose |
|
|
547
|
+
| ------------------------------------------------------------- | ---------------------------------------------------------- |
|
|
548
|
+
| `GET /.well-known/oauth-protected-resource[/<resource path>]` | RFC 9728 metadata. |
|
|
549
|
+
| `GET /.well-known/oauth-authorization-server[/<issuer path>]` | RFC 8414 metadata. |
|
|
550
|
+
| `POST /register` | RFC 7591 registration of public clients. |
|
|
551
|
+
| `GET /authorize` | Starts a flow and redirects to the identity provider. |
|
|
552
|
+
| `GET /callback` | Back from the identity provider; shows the consent screen. |
|
|
553
|
+
| `POST /consent` | The person's answer. |
|
|
554
|
+
| `POST /token` | `authorization_code` and `refresh_token` grants. |
|
|
555
|
+
| `POST /revoke` | RFC 7009 revocation. |
|
|
556
|
+
|
|
557
|
+
### Building blocks
|
|
558
|
+
|
|
559
|
+
| Export | What it does |
|
|
560
|
+
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
561
|
+
| `createOidcProvider(options)` | Any OIDC provider: discovery, code flow with PKCE and nonce, ID token verification against the provider's keys (asymmetric algorithms only), RFC 9207 checks, renewal and revocation. |
|
|
562
|
+
| `createAesGcmSealer(options)` | AES-256-GCM with a random IV per value, the grant id as authenticated data, and key ids for rotation. |
|
|
563
|
+
| `createMemoryStore(options?)` | A correct **single-process, non-persistent** store for development and tests. |
|
|
564
|
+
| `escapeHtml(text)` | The escaping the built-in pages use, for your own page renderers. |
|
|
565
|
+
|
|
566
|
+
### Errors
|
|
567
|
+
|
|
568
|
+
These classes reach your own code, for example from
|
|
569
|
+
`getUpstreamAccessToken()`; requests to the OAuth routes are answered with
|
|
570
|
+
OAuth errors or error pages instead. No error message contains a token.
|
|
571
|
+
|
|
572
|
+
| Class | Thrown when |
|
|
573
|
+
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
574
|
+
| `McpAuthConfigError` | `createMcpAuth` options are invalid, or `revokeGrantsForSubject` is called on a store without `listGrantsBySubject`. |
|
|
575
|
+
| `GrantInactiveError` | `getUpstreamAccessToken()` or `renewUpstream()` runs for a grant that was revoked or has expired. |
|
|
576
|
+
| `UpstreamTokenError` | No upstream access token could be obtained for a live grant. |
|
|
577
|
+
| `IdentityProviderError` | A call to the identity provider failed (discovery, code exchange, ID token checks, renewal). `code` is the provider's OAuth error, when it sent one. |
|
|
578
|
+
| `SealerError` | A sealed value is malformed, was altered, belongs to another owner, or uses a key id that is not configured. |
|
|
579
|
+
|
|
580
|
+
### Registered metadata is enforced
|
|
581
|
+
|
|
582
|
+
- **`grant_types`**: a client that omits it gets `authorization_code` only
|
|
583
|
+
(RFC 7591 § 2) and **no refresh token**. Clients that want refresh tokens
|
|
584
|
+
register `["authorization_code", "refresh_token"]`. Metadata documents
|
|
585
|
+
are treated the same way.
|
|
586
|
+
- **`scope`**: a client that registers a scope can only request scopes
|
|
587
|
+
within it. Unknown scopes are refused at registration and ignored in a
|
|
588
|
+
metadata document.
|
|
589
|
+
|
|
590
|
+
### Client ID Metadata Documents
|
|
591
|
+
|
|
592
|
+
Downloaded with [`@gydr-ai/safe-fetch`](https://github.com/gydr-ai/safe-fetch)
|
|
593
|
+
by default: private and metadata addresses refused, the connection pinned
|
|
594
|
+
against DNS rebinding, no redirects. Whatever fetcher you plug in, the core
|
|
595
|
+
still enforces the deadline, the size cap, a `200` status and the document
|
|
596
|
+
checks (`client_id` equal to the URL, public client, usable redirect URIs).
|
|
597
|
+
A document listing `token_endpoint_auth_methods_supported` without `none`
|
|
598
|
+
is a confidential client and refused, and so is a client id equal to the
|
|
599
|
+
issuer. A document's `Cache-Control` (`max-age`, `no-store`, `no-cache`)
|
|
600
|
+
is honoured, never beyond `lifetimes.clientMetadataCache`. Failed URLs back
|
|
601
|
+
off, and concurrent requests for one URL share a download.
|
|
602
|
+
|
|
603
|
+
## Security model
|
|
604
|
+
|
|
605
|
+
**What it guarantees**
|
|
606
|
+
|
|
607
|
+
- Authorization code flow, PKCE `S256` and public clients only. Client
|
|
608
|
+
credentials at the token endpoint are refused: with a 401 and a
|
|
609
|
+
`WWW-Authenticate` challenge in the client's scheme when sent in the
|
|
610
|
+
`Authorization` header, otherwise `400 invalid_client` (RFC 6749 § 5.2).
|
|
611
|
+
- Redirect URIs are `https`, or `http` to a loopback host where only the
|
|
612
|
+
port may vary (RFC 8252 § 7.3); otherwise compared exactly, and may not
|
|
613
|
+
already carry `code`, `state`, `iss` or an error parameter. Every problem
|
|
614
|
+
at `/authorize` is shown as a page, never sent as a redirect, because the
|
|
615
|
+
person has not signed in yet (RFC 9700 § 4.11.2). A repeated defined
|
|
616
|
+
parameter is refused; unknown parameters are ignored.
|
|
617
|
+
- The browser that starts a flow must finish it: a per-flow `__Host-`
|
|
618
|
+
cookie is compared with its stored hash before anything is consumed.
|
|
619
|
+
- The consent form carries a one-time token compared in constant time. A
|
|
620
|
+
flow that ends without a grant revokes the upstream refresh token it
|
|
621
|
+
obtained.
|
|
622
|
+
- Pages send a strict Content-Security-Policy, `X-Frame-Options: DENY`,
|
|
623
|
+
`Cache-Control: no-store` and `Referrer-Policy: no-referrer`, and escape
|
|
624
|
+
every value. Client names with invisible or formatting characters are
|
|
625
|
+
refused.
|
|
626
|
+
- Tokens are 256-bit random values, stored only as SHA-256 hashes, bound to
|
|
627
|
+
your resource and checked against a live grant on every request. Refresh
|
|
628
|
+
tokens rotate on every use; a replay ends the grant, and so does a fully
|
|
629
|
+
valid repeat of an authorization code. A code presented with the wrong
|
|
630
|
+
client, redirect URI, resource or verifier is refused without being used
|
|
631
|
+
up and without ending anything, so whoever holds a stolen code can
|
|
632
|
+
neither spend it nor end the person's session by guessing verifiers
|
|
633
|
+
(256 bits, under your rate limit).
|
|
634
|
+
- Upstream tokens are encrypted with the grant id as authenticated data and
|
|
635
|
+
never reach MCP clients or `onEvent`.
|
|
636
|
+
|
|
637
|
+
**What it does not do**
|
|
638
|
+
|
|
639
|
+
- Authorize individual MCP tools. Check `result.scopes` and your own
|
|
640
|
+
permissions, and answer with
|
|
641
|
+
`auth.unauthorized(request, { error: "insufficient_scope", scope })`.
|
|
642
|
+
- Act as an identity provider: no passwords, MFA or account management.
|
|
643
|
+
- Confidential MCP clients, implicit or password grants, JWT access tokens,
|
|
644
|
+
introspection, PAR, DPoP or mTLS.
|
|
645
|
+
- Coordinate across processes beyond your store. If your identity provider
|
|
646
|
+
rotates refresh tokens with no grace period and several processes may
|
|
647
|
+
renew one grant at once, route a grant's renewals to one process.
|
|
648
|
+
- Catch a refresh token copied and used within `refreshReuseGraceSeconds`
|
|
649
|
+
of the real use. Use `0` if your clients never retry.
|
|
650
|
+
- Protect against a compromised browser, a client the person chose to
|
|
651
|
+
approve, or a compromised identity provider.
|
|
652
|
+
|
|
653
|
+
## Specification compliance
|
|
654
|
+
|
|
655
|
+
| Specification | Status |
|
|
656
|
+
| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
657
|
+
| MCP Authorization (2026-07-28) | Supported, including Client ID Metadata Documents and per-authorization consent; deprecated DCR on by default. |
|
|
658
|
+
| OAuth 2.1 (draft-17) | PKCE required, exact redirect matching, refresh rotation, single-use codes, optional `redirect_uri` at `/token`, 303 after the consent form. |
|
|
659
|
+
| RFC 6749 OAuth 2.0 | Code and refresh grants; errors per § 4.1.2.1 and § 5.2. |
|
|
660
|
+
| RFC 6750 Bearer Token Usage | Authorization header only; challenges per § 3 (bare when no Bearer credentials). |
|
|
661
|
+
| RFC 7009 Token Revocation | Supported; unknown tokens answer 200. |
|
|
662
|
+
| RFC 7591 Dynamic Client Registration | Public clients; `grant_types` and `scope` enforced. RFC 7592 not supported. |
|
|
663
|
+
| RFC 7636 PKCE | S256 only. |
|
|
664
|
+
| RFC 8252 Native Apps | Loopback redirects with any port; custom schemes not accepted. |
|
|
665
|
+
| RFC 8414 Authorization Server Metadata | Supported, including path-suffixed well-known URLs. |
|
|
666
|
+
| RFC 8707 Resource Indicators | Required at `/authorize`, checked at `/token` (scheme and host case-insensitive), enforced as the audience. |
|
|
667
|
+
| RFC 9207 Issuer Identification | `iss` on every authorization response; checked toward the identity provider. |
|
|
668
|
+
| RFC 9700 OAuth Security BCP | Refresh replay detection, no redirect before sign-in, CSRF and login-CSRF protection. |
|
|
669
|
+
| RFC 9728 Protected Resource Metadata | Supported, with `resource_metadata` challenges (§ 5.1). |
|
|
670
|
+
| OAuth Client ID Metadata Document (draft-03) | Public clients; plural auth methods, `Cache-Control`, 5 KiB cap, issuer-equal ids refused. |
|
|
671
|
+
| OpenID Connect Core and Discovery | Used toward the identity provider. |
|
|
672
|
+
|
|
673
|
+
## Compatibility
|
|
674
|
+
|
|
675
|
+
| Area | Supported |
|
|
676
|
+
| ------------------ | ----------------------------------------------------------------------------------------- |
|
|
677
|
+
| Runtime | Node.js 26, 24 LTS and 22 LTS (22.12 or later), tested in CI |
|
|
678
|
+
| Frameworks | Anything that passes a Web `Request` and returns a `Response` |
|
|
679
|
+
| Identity providers | Any OpenID Connect provider with discovery; anything else via a custom `IdentityProvider` |
|
|
680
|
+
| Stores | Any database with an atomic conditional write |
|
|
681
|
+
| Key management | The bundled AES-256-GCM sealer, or a custom `SecretSealer` over any KMS or HSM |
|
|
682
|
+
|
|
683
|
+
## Security
|
|
684
|
+
|
|
685
|
+
Report vulnerabilities privately, as described in [SECURITY.md](SECURITY.md).
|
|
686
|
+
Please do not open a public issue for a vulnerability.
|
|
687
|
+
|
|
688
|
+
Every release is built and published by GitHub Actions with
|
|
689
|
+
[npm provenance](https://docs.npmjs.com/generating-provenance-statements),
|
|
690
|
+
and a maintainer approves each one with a hardware security key. Verify what
|
|
691
|
+
you installed with:
|
|
692
|
+
|
|
693
|
+
```bash
|
|
694
|
+
npm audit signatures
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
## Contributing
|
|
698
|
+
|
|
699
|
+
Issues and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md)
|
|
700
|
+
for the development setup and the checks a change must pass.
|
|
701
|
+
|
|
702
|
+
## About Gydr
|
|
703
|
+
|
|
704
|
+
`@gydr-ai/mcp-oauth` is built and maintained by [Gydr](https://gydr.ai), an
|
|
705
|
+
AI chatbot platform that answers customers on websites, WhatsApp, Messenger,
|
|
706
|
+
Instagram and Telegram. Businesses on Gydr manage their chatbots from Claude,
|
|
707
|
+
Codex and other MCP clients, and this package grew out of the authorization
|
|
708
|
+
server that makes those connections safe. See also
|
|
709
|
+
[`@gydr-ai/safe-fetch`](https://github.com/gydr-ai/safe-fetch), SSRF-safe
|
|
710
|
+
`fetch` for Node.js.
|
|
711
|
+
|
|
712
|
+
## License
|
|
713
|
+
|
|
714
|
+
[Apache-2.0](LICENSE) © [Gydr.ai](https://gydr.ai)
|
|
715
|
+
|
|
716
|
+
<p align="center">
|
|
717
|
+
<a href="https://gydr.ai"><img alt="Gydr" src="https://img.shields.io/badge/made_by-Gydr-0f172a?style=for-the-badge" /></a>
|
|
718
|
+
</p>
|