@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.
Files changed (85) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +4 -0
  3. package/README.md +717 -2
  4. package/dist/index.js +47 -0
  5. package/dist/index.js.map +7 -0
  6. package/dist/types/constants/defaults.d.ts +1 -0
  7. package/dist/types/constants/error-pages.d.ts +21 -0
  8. package/dist/types/constants/oidc.d.ts +1 -0
  9. package/dist/types/constants/protocol.d.ts +1 -0
  10. package/dist/types/core/authenticate.d.ts +1 -0
  11. package/dist/types/core/client-form.d.ts +1 -0
  12. package/dist/types/core/client-metadata.d.ts +11 -0
  13. package/dist/types/core/clients.d.ts +1 -0
  14. package/dist/types/core/code-exchange.d.ts +1 -0
  15. package/dist/types/core/consent-page.d.ts +1 -0
  16. package/dist/types/core/context.d.ts +1 -0
  17. package/dist/types/core/grant-context.d.ts +1 -0
  18. package/dist/types/core/grant-summary.d.ts +1 -0
  19. package/dist/types/core/grants.d.ts +1 -0
  20. package/dist/types/core/metadata.d.ts +1 -0
  21. package/dist/types/core/pages.d.ts +1 -0
  22. package/dist/types/core/policy.d.ts +7 -0
  23. package/dist/types/core/refresh-exchange.d.ts +1 -0
  24. package/dist/types/core/resolve-clients.d.ts +1 -0
  25. package/dist/types/core/resolve-options.d.ts +1 -0
  26. package/dist/types/core/resolve-parts.d.ts +1 -0
  27. package/dist/types/core/router.d.ts +1 -0
  28. package/dist/types/core/server.d.ts +50 -0
  29. package/dist/types/core/subject.d.ts +1 -0
  30. package/dist/types/core/token-policy.d.ts +1 -0
  31. package/dist/types/core/upstream-tokens.d.ts +1 -0
  32. package/dist/types/crypto/aes-gcm-sealer.d.ts +27 -0
  33. package/dist/types/errors/body-too-large-error.d.ts +1 -0
  34. package/dist/types/errors/client-metadata-error.d.ts +1 -0
  35. package/dist/types/errors/endpoint-refusal.d.ts +1 -0
  36. package/dist/types/errors/grant-inactive-error.d.ts +26 -0
  37. package/dist/types/errors/identity-provider-error.d.ts +30 -0
  38. package/dist/types/errors/mcp-auth-config-error.d.ts +23 -0
  39. package/dist/types/errors/sealer-error.d.ts +24 -0
  40. package/dist/types/errors/upstream-token-error.d.ts +28 -0
  41. package/dist/types/handlers/authorize.d.ts +1 -0
  42. package/dist/types/handlers/callback.d.ts +1 -0
  43. package/dist/types/handlers/consent.d.ts +1 -0
  44. package/dist/types/handlers/register.d.ts +1 -0
  45. package/dist/types/handlers/revoke.d.ts +1 -0
  46. package/dist/types/handlers/token.d.ts +1 -0
  47. package/dist/types/helpers/body.d.ts +1 -0
  48. package/dist/types/helpers/client-fields.d.ts +1 -0
  49. package/dist/types/helpers/client-ip.d.ts +1 -0
  50. package/dist/types/helpers/cookies.d.ts +1 -0
  51. package/dist/types/helpers/freeze.d.ts +1 -0
  52. package/dist/types/helpers/guards.d.ts +1 -0
  53. package/dist/types/helpers/html-document.d.ts +1 -0
  54. package/dist/types/helpers/json.d.ts +1 -0
  55. package/dist/types/helpers/locales.d.ts +1 -0
  56. package/dist/types/helpers/params.d.ts +1 -0
  57. package/dist/types/helpers/redirect-uris.d.ts +12 -0
  58. package/dist/types/helpers/resource.d.ts +1 -0
  59. package/dist/types/helpers/responses.d.ts +1 -0
  60. package/dist/types/helpers/scopes.d.ts +1 -0
  61. package/dist/types/helpers/text.d.ts +32 -0
  62. package/dist/types/helpers/time.d.ts +1 -0
  63. package/dist/types/helpers/tokens.d.ts +1 -0
  64. package/dist/types/index.d.ts +30 -0
  65. package/dist/types/interfaces/auth.d.ts +151 -0
  66. package/dist/types/interfaces/consent.d.ts +164 -0
  67. package/dist/types/interfaces/context.d.ts +1 -0
  68. package/dist/types/interfaces/events.d.ts +166 -0
  69. package/dist/types/interfaces/identity-provider.d.ts +251 -0
  70. package/dist/types/interfaces/memory-store.d.ts +27 -0
  71. package/dist/types/interfaces/oidc.d.ts +55 -0
  72. package/dist/types/interfaces/options.d.ts +575 -0
  73. package/dist/types/interfaces/pages.d.ts +98 -0
  74. package/dist/types/interfaces/policy.d.ts +355 -0
  75. package/dist/types/interfaces/records.d.ts +383 -0
  76. package/dist/types/interfaces/routes.d.ts +1 -0
  77. package/dist/types/interfaces/sealer.d.ts +67 -0
  78. package/dist/types/interfaces/store.d.ts +195 -0
  79. package/dist/types/interfaces/token-response.d.ts +1 -0
  80. package/dist/types/providers/oidc-documents.d.ts +1 -0
  81. package/dist/types/providers/oidc-provider.d.ts +31 -0
  82. package/dist/types/stores/client-collection.d.ts +1 -0
  83. package/dist/types/stores/collection.d.ts +1 -0
  84. package/dist/types/stores/memory-store.d.ts +22 -0
  85. package/package.json +71 -4
package/README.md CHANGED
@@ -1,3 +1,718 @@
1
- # Temporary Holding Version
1
+ <h1 align="center">@gydr-ai/mcp-oauth</h1>
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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>