@ttoss/http-server-mcp 0.13.8 → 0.15.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 +168 -2
- package/dist/index.cjs +430 -0
- package/dist/index.d.cts +308 -3
- package/dist/index.d.mts +308 -3
- package/dist/index.mjs +428 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -169,7 +169,7 @@ const mcpRouter = createMcpRouter(mcpServer, {
|
|
|
169
169
|
|
|
170
170
|
### Custom verifier
|
|
171
171
|
|
|
172
|
-
Pass an async `verifyToken` function for any
|
|
172
|
+
Pass an async `verifyToken` function for any provider — JWT-based or opaque. The contract is simply: resolve with an identity payload on success, or throw on failure.
|
|
173
173
|
|
|
174
174
|
```typescript
|
|
175
175
|
import { createMcpRouter } from '@ttoss/http-server-mcp';
|
|
@@ -189,6 +189,25 @@ const mcpRouter = createMcpRouter(mcpServer, {
|
|
|
189
189
|
});
|
|
190
190
|
```
|
|
191
191
|
|
|
192
|
+
**Opaque token (database lookup):** `verifyToken` does not have to be JWT-based — a plain API-key lookup works equally well:
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
const mcpRouter = createMcpRouter(mcpServer, {
|
|
196
|
+
auth: {
|
|
197
|
+
verifyToken: async (token) => {
|
|
198
|
+
// Look up the hashed token in your database
|
|
199
|
+
const record = await db.apiKeys.findByHash(sha256(token));
|
|
200
|
+
if (!record || record.revokedAt) {
|
|
201
|
+
throw new Error('Invalid API key');
|
|
202
|
+
}
|
|
203
|
+
return { sub: record.userId, scope: record.scopes.join(' ') };
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
The router emits `401 Unauthorized` whenever `verifyToken` throws, regardless of whether you are using JWTs or opaque tokens.
|
|
210
|
+
|
|
192
211
|
### Accessing the verified identity
|
|
193
212
|
|
|
194
213
|
Inside any tool handler, call `getIdentity()` to retrieve the verified JWT payload:
|
|
@@ -245,7 +264,9 @@ Cognito encodes scopes as a space-separated string in `payload.scope` (e.g. `"op
|
|
|
245
264
|
|
|
246
265
|
### OAuth Protected Resource Metadata
|
|
247
266
|
|
|
248
|
-
|
|
267
|
+
MCP clients (Claude, Cursor, etc.) fetch `/.well-known/oauth-protected-resource` to discover which authorization server issues tokens for your MCP server. The endpoint must be **unauthenticated** — MCP clients call it before they have a token.
|
|
268
|
+
|
|
269
|
+
**With the built-in `auth` option** — add `resourceServerUrl` and `authorizationServerUrl`:
|
|
249
270
|
|
|
250
271
|
```typescript
|
|
251
272
|
createMcpRouter(mcpServer, {
|
|
@@ -258,6 +279,111 @@ createMcpRouter(mcpServer, {
|
|
|
258
279
|
});
|
|
259
280
|
```
|
|
260
281
|
|
|
282
|
+
**With your own auth middleware** — use `createProtectedResourceMetadataMiddleware` as a standalone middleware, mounted _before_ your auth layer so discovery stays unauthenticated:
|
|
283
|
+
|
|
284
|
+
```typescript
|
|
285
|
+
import {
|
|
286
|
+
createProtectedResourceMetadataMiddleware,
|
|
287
|
+
getWwwAuthenticateHeader,
|
|
288
|
+
} from '@ttoss/http-server-mcp';
|
|
289
|
+
|
|
290
|
+
// Mount the discovery endpoint before your own auth middleware
|
|
291
|
+
app.use(
|
|
292
|
+
createProtectedResourceMetadataMiddleware({
|
|
293
|
+
resource: 'https://mcp.example.com',
|
|
294
|
+
authorizationServers: ['https://api.example.com'],
|
|
295
|
+
})
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
// Your own auth middleware — emit the spec-compliant WWW-Authenticate header on 401s
|
|
299
|
+
app.use(async (ctx, next) => {
|
|
300
|
+
const token = ctx.headers.authorization?.replace('Bearer ', '');
|
|
301
|
+
if (!token || !(await myVerify(token))) {
|
|
302
|
+
ctx.status = 401;
|
|
303
|
+
ctx.set(
|
|
304
|
+
'WWW-Authenticate',
|
|
305
|
+
getWwwAuthenticateHeader({ resource: 'https://mcp.example.com' })
|
|
306
|
+
);
|
|
307
|
+
ctx.body = 'Unauthorized';
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
await next();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
app.use(createMcpRouter(mcpServer).routes());
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
The `WWW-Authenticate: Bearer resource_metadata="…"` header is how MCP clients bootstrap OAuth discovery after their first unauthorized request.
|
|
317
|
+
|
|
318
|
+
## OAuth 2.1 Authorization Server
|
|
319
|
+
|
|
320
|
+
The `auth` option above covers the **resource-server** half of MCP authorization — it verifies tokens issued by an external authorization server (Cognito, Auth0, …). When you want your own first-party server to _issue_ the tokens, `createMcpAuthServer` provides the **authorization-server** half: the `/authorize`, `/token`, `/register`, and discovery endpoints an MCP client (Claude, Cursor, VS Code) auto-discovers and runs the full OAuth 2.1 flow against.
|
|
321
|
+
|
|
322
|
+
ttoss owns only the protocol mechanics (PKCE, discovery metadata, code exchange, dynamic client registration). Your app keeps its own user model, token signing, and login/consent UI through pluggable hooks — the user model never leaves your app.
|
|
323
|
+
|
|
324
|
+
```typescript
|
|
325
|
+
import { App, bodyParser } from '@ttoss/http-server';
|
|
326
|
+
import { createMcpAuthServer } from '@ttoss/http-server-mcp';
|
|
327
|
+
|
|
328
|
+
const authServer = createMcpAuthServer({
|
|
329
|
+
issuer: 'https://api.example.com',
|
|
330
|
+
clientStore, // register/lookup dynamic clients
|
|
331
|
+
authCodeStore, // persist short-lived codes + PKCE challenge
|
|
332
|
+
// App-owned token minting — ttoss never sees your signing keys
|
|
333
|
+
issueTokens: async ({ subject, scopes }) => ({
|
|
334
|
+
accessToken: signJwt({ sub: subject, scope: scopes.join(' ') }),
|
|
335
|
+
refreshToken: createRefreshToken(subject),
|
|
336
|
+
expiresIn: 3600,
|
|
337
|
+
}),
|
|
338
|
+
// App-owned login/consent — show your own UI, then approve
|
|
339
|
+
onAuthorize: async ({ ctx, request }) => {
|
|
340
|
+
const session = await getSession(ctx);
|
|
341
|
+
if (!session) {
|
|
342
|
+
ctx.redirect(`/login?return_to=${encodeURIComponent(ctx.url)}`);
|
|
343
|
+
return { approved: false };
|
|
344
|
+
}
|
|
345
|
+
return { approved: true, subject: session.userId, scopes: request.scopes };
|
|
346
|
+
},
|
|
347
|
+
// App-owned refresh validation — required to enable the refresh_token grant
|
|
348
|
+
onRefreshToken: async ({ refreshToken }) => {
|
|
349
|
+
const claims = await verifyRefreshToken(refreshToken);
|
|
350
|
+
return claims ? { subject: claims.sub, scopes: claims.scopes } : undefined;
|
|
351
|
+
},
|
|
352
|
+
scopesSupported: ['mcp:access'],
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const app = new App();
|
|
356
|
+
app.use(bodyParser());
|
|
357
|
+
app.use(authServer.routes());
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
This mounts:
|
|
361
|
+
|
|
362
|
+
| Endpoint | Spec | Purpose |
|
|
363
|
+
| ----------------------------------------- | --------- | ---------------------------------------------------- |
|
|
364
|
+
| `/.well-known/oauth-authorization-server` | RFC 8414 | Authorization server metadata discovery |
|
|
365
|
+
| `/.well-known/oauth-protected-resource` | RFC 9728 | Protected-resource metadata (when `resource` is set) |
|
|
366
|
+
| `/authorize` | OAuth 2.1 | Authorization endpoint — **PKCE (S256) required** |
|
|
367
|
+
| `/token` | OAuth 2.1 | `authorization_code` + `refresh_token` grants |
|
|
368
|
+
| `/register` | RFC 7591 | Dynamic Client Registration |
|
|
369
|
+
|
|
370
|
+
The full flow an MCP client runs: it fetches the discovery document, self-registers at `/register`, opens `/authorize` (your `onAuthorize` handles login/consent), exchanges the returned code at `/token` with its PKCE verifier, and uses the access token against your `createMcpRouter` endpoint. Pair this with `createMcpRouter({ auth: { verifyToken } })` so the same server both issues and verifies tokens.
|
|
371
|
+
|
|
372
|
+
The pluggable stores let you keep persistence in your own datastore:
|
|
373
|
+
|
|
374
|
+
```typescript
|
|
375
|
+
const clientStore: ClientStore = {
|
|
376
|
+
get: (clientId) => db.clients.findById(clientId),
|
|
377
|
+
register: (client) => db.clients.put(client),
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const authCodeStore: AuthCodeStore = {
|
|
381
|
+
save: (code) => db.codes.put(code), // set a TTL matching authorizationCodeTtl
|
|
382
|
+
get: (code) => db.codes.findById(code),
|
|
383
|
+
delete: (code) => db.codes.remove(code),
|
|
384
|
+
};
|
|
385
|
+
```
|
|
386
|
+
|
|
261
387
|
## API Reference
|
|
262
388
|
|
|
263
389
|
### `createMcpRouter(server, options?)`
|
|
@@ -280,6 +406,25 @@ Creates a Koa router configured to handle MCP protocol requests.
|
|
|
280
406
|
|
|
281
407
|
**Returns:** `Router` — Koa router instance
|
|
282
408
|
|
|
409
|
+
### `createMcpAuthServer(options)`
|
|
410
|
+
|
|
411
|
+
Creates an OAuth 2.1 Authorization Server (`/authorize`, `/token`, `/register`, and discovery metadata) for MCP clients. See [OAuth 2.1 Authorization Server](#oauth-21-authorization-server).
|
|
412
|
+
|
|
413
|
+
**Parameters (`McpAuthServerOptions`):**
|
|
414
|
+
|
|
415
|
+
- `issuer` (`string`) — The authorization server's issuer identifier (its base URL); used to build absolute endpoint URLs in the discovery document
|
|
416
|
+
- `clientStore` (`ClientStore`) — `{ get(clientId), register(client) }` for dynamic clients
|
|
417
|
+
- `authCodeStore` (`AuthCodeStore`) — `{ save(code), get(code), delete(code) }` for short-lived authorization codes (codes are single-use)
|
|
418
|
+
- `issueTokens` (`({ subject, scopes, client }) => IssuedTokens`) — App-owned token minting; returns `{ accessToken, refreshToken?, expiresIn?, scope? }`
|
|
419
|
+
- `onAuthorize` (`({ ctx, client, request }) => OnAuthorizeResult`) — App-owned login/consent; return `{ approved: true, subject, scopes? }` to issue a code, or take over the response and return `{ approved: false }`
|
|
420
|
+
- `onRefreshToken` (`({ refreshToken, client, scopes }) => { subject, scopes } | undefined`, optional) — App-owned refresh-token validation; required to enable the `refresh_token` grant
|
|
421
|
+
- `scopesSupported` (`string[]`, optional) — Advertised in discovery metadata as `scopes_supported`
|
|
422
|
+
- `resource` (`string`, optional) — When set, also serves `/.well-known/oauth-protected-resource`
|
|
423
|
+
- `authorizationCodeTtl` (`number`, optional) — Authorization code lifetime in seconds (default: `600`)
|
|
424
|
+
- `endpoints` (`{ authorize?, token?, register? }`, optional) — Override the default endpoint paths
|
|
425
|
+
|
|
426
|
+
**Returns:** `Router` — Koa router instance exposing `routes()` / `allowedMethods()`
|
|
427
|
+
|
|
283
428
|
### `apiCall(method, url, options?)`
|
|
284
429
|
|
|
285
430
|
Generic HTTP helper for use inside MCP tool handlers.
|
|
@@ -307,6 +452,27 @@ Asserts that the current request token contains all required scopes. Throws `Err
|
|
|
307
452
|
|
|
308
453
|
- `required` (`string[]`) — Scope strings that must all be present in `payload.scope`
|
|
309
454
|
|
|
455
|
+
### `createProtectedResourceMetadataMiddleware(args)`
|
|
456
|
+
|
|
457
|
+
Creates a standalone Koa middleware that serves `GET /.well-known/oauth-protected-resource` (RFC 9728). Use this when you have your own auth middleware and don't want to tie the discovery endpoint to the built-in `auth` option.
|
|
458
|
+
|
|
459
|
+
**Parameters:**
|
|
460
|
+
|
|
461
|
+
- `args.resource` (`string`) — The protected resource's identifier URI (your MCP server URL)
|
|
462
|
+
- `args.authorizationServers` (`string[]`) — Issuer URIs of the authorization servers that protect this resource
|
|
463
|
+
|
|
464
|
+
**Returns:** `Koa.Middleware`
|
|
465
|
+
|
|
466
|
+
### `getWwwAuthenticateHeader(args)`
|
|
467
|
+
|
|
468
|
+
Returns the `WWW-Authenticate` header value for a 401 response, formatted per the MCP auth spec: `Bearer resource_metadata="<resource>/.well-known/oauth-protected-resource"`.
|
|
469
|
+
|
|
470
|
+
**Parameters:**
|
|
471
|
+
|
|
472
|
+
- `args.resource` (`string`) — The protected resource URL (trailing slash is stripped automatically)
|
|
473
|
+
|
|
474
|
+
**Returns:** `string` — The full `WWW-Authenticate` header value
|
|
475
|
+
|
|
310
476
|
### `registerToolFromSchema(server, params)`
|
|
311
477
|
|
|
312
478
|
Registers a tool using a **plain JSON Schema** object for `inputSchema` instead of a Zod shape.
|
package/dist/index.cjs
CHANGED
|
@@ -7,8 +7,379 @@ let _modelcontextprotocol_sdk_server_streamableHttp_js = require("@modelcontextp
|
|
|
7
7
|
let _ttoss_auth_core_amazon_cognito = require("@ttoss/auth-core/amazon-cognito");
|
|
8
8
|
let _ttoss_http_server = require("@ttoss/http-server");
|
|
9
9
|
let zod = require("zod");
|
|
10
|
+
let node_crypto = require("node:crypto");
|
|
10
11
|
let _modelcontextprotocol_sdk_server_mcp_js = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
11
12
|
|
|
13
|
+
//#region src/authServerHandlers.ts
|
|
14
|
+
var base64UrlEncode = buffer => {
|
|
15
|
+
return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
16
|
+
};
|
|
17
|
+
/** Generates a URL-safe random token of the given byte length. */
|
|
18
|
+
var generateToken = (bytes = 32) => {
|
|
19
|
+
return base64UrlEncode((0, node_crypto.randomBytes)(bytes));
|
|
20
|
+
};
|
|
21
|
+
var sha256Base64Url = input => {
|
|
22
|
+
return base64UrlEncode((0, node_crypto.createHash)("sha256").update(input).digest());
|
|
23
|
+
};
|
|
24
|
+
/** Verifies a PKCE `code_verifier` against a stored S256 `code_challenge`. */
|
|
25
|
+
var verifyPkce = (codeVerifier, codeChallenge) => {
|
|
26
|
+
return sha256Base64Url(codeVerifier) === codeChallenge;
|
|
27
|
+
};
|
|
28
|
+
/** Joins an issuer base URL with a path, collapsing any duplicate slash. */
|
|
29
|
+
var joinUrl = (issuer, path) => {
|
|
30
|
+
return `${issuer.replace(/\/$/, "")}${path}`;
|
|
31
|
+
};
|
|
32
|
+
/** Returns the value if it is a string, otherwise `undefined`. */
|
|
33
|
+
var asString = value => {
|
|
34
|
+
return typeof value === "string" ? value : void 0;
|
|
35
|
+
};
|
|
36
|
+
var parseScopes = scope => {
|
|
37
|
+
const value = asString(scope);
|
|
38
|
+
return value ? value.split(" ").filter(Boolean) : [];
|
|
39
|
+
};
|
|
40
|
+
/** Writes a spec-compliant OAuth error response (RFC 6749 §5.2). */
|
|
41
|
+
var sendOAuthError = (ctx, status, error, description) => {
|
|
42
|
+
ctx.status = status;
|
|
43
|
+
ctx.body = {
|
|
44
|
+
error,
|
|
45
|
+
error_description: description
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
var buildTokenResponse = (tokens, scopes) => {
|
|
49
|
+
return {
|
|
50
|
+
access_token: tokens.accessToken,
|
|
51
|
+
token_type: "Bearer",
|
|
52
|
+
...(tokens.expiresIn !== void 0 ? {
|
|
53
|
+
expires_in: tokens.expiresIn
|
|
54
|
+
} : {}),
|
|
55
|
+
...(tokens.refreshToken !== void 0 ? {
|
|
56
|
+
refresh_token: tokens.refreshToken
|
|
57
|
+
} : {}),
|
|
58
|
+
scope: tokens.scope ?? scopes.join(" ")
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Authenticates the client at the token endpoint via `client_secret_basic`
|
|
63
|
+
* (Authorization header), `client_secret_post` (body), or `none` (public,
|
|
64
|
+
* PKCE-only). Returns the client, or `undefined` when authentication fails.
|
|
65
|
+
*/
|
|
66
|
+
var authenticateClient = async (ctx, body, clientStore) => {
|
|
67
|
+
let clientId = asString(body.client_id);
|
|
68
|
+
let clientSecret = asString(body.client_secret);
|
|
69
|
+
const authHeader = ctx.headers.authorization;
|
|
70
|
+
if (authHeader?.startsWith("Basic ")) {
|
|
71
|
+
const decoded = Buffer.from(authHeader.slice(6), "base64").toString();
|
|
72
|
+
const separatorIndex = decoded.indexOf(":");
|
|
73
|
+
if (separatorIndex !== -1) {
|
|
74
|
+
clientId = decodeURIComponent(decoded.slice(0, separatorIndex));
|
|
75
|
+
clientSecret = decodeURIComponent(decoded.slice(separatorIndex + 1));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (!clientId) return;
|
|
79
|
+
const client = await clientStore.get(clientId);
|
|
80
|
+
if (!client) return;
|
|
81
|
+
if (client.client_secret && client.client_secret !== clientSecret) return;
|
|
82
|
+
return client;
|
|
83
|
+
};
|
|
84
|
+
/** Handles `grant_type=authorization_code` token requests (with PKCE verify). */
|
|
85
|
+
var handleAuthorizationCodeGrant = async (ctx, body, options) => {
|
|
86
|
+
const {
|
|
87
|
+
clientStore,
|
|
88
|
+
authCodeStore,
|
|
89
|
+
issueTokens
|
|
90
|
+
} = options;
|
|
91
|
+
const client = await authenticateClient(ctx, body, clientStore);
|
|
92
|
+
if (!client) {
|
|
93
|
+
sendOAuthError(ctx, 401, "invalid_client", "client authentication failed");
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const code = asString(body.code);
|
|
97
|
+
if (!code) {
|
|
98
|
+
sendOAuthError(ctx, 400, "invalid_request", "code is required");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const stored = await authCodeStore.get(code);
|
|
102
|
+
await authCodeStore.delete(code);
|
|
103
|
+
if (!stored) {
|
|
104
|
+
sendOAuthError(ctx, 400, "invalid_grant", "invalid or expired authorization code");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (stored.expiresAt < Date.now()) {
|
|
108
|
+
sendOAuthError(ctx, 400, "invalid_grant", "authorization code expired");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (stored.clientId !== client.client_id) {
|
|
112
|
+
sendOAuthError(ctx, 400, "invalid_grant", "authorization code was not issued to this client");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (stored.redirectUri !== asString(body.redirect_uri)) {
|
|
116
|
+
sendOAuthError(ctx, 400, "invalid_grant", "redirect_uri mismatch");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const codeVerifier = asString(body.code_verifier);
|
|
120
|
+
if (!codeVerifier) {
|
|
121
|
+
sendOAuthError(ctx, 400, "invalid_request", "code_verifier is required");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (!verifyPkce(codeVerifier, stored.codeChallenge)) {
|
|
125
|
+
sendOAuthError(ctx, 400, "invalid_grant", "PKCE verification failed");
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
ctx.body = buildTokenResponse(await issueTokens({
|
|
129
|
+
subject: stored.subject,
|
|
130
|
+
scopes: stored.scopes,
|
|
131
|
+
client
|
|
132
|
+
}), stored.scopes);
|
|
133
|
+
};
|
|
134
|
+
/** Handles `grant_type=refresh_token` token requests. */
|
|
135
|
+
var handleRefreshTokenGrant = async (ctx, body, options) => {
|
|
136
|
+
const {
|
|
137
|
+
clientStore,
|
|
138
|
+
issueTokens,
|
|
139
|
+
onRefreshToken
|
|
140
|
+
} = options;
|
|
141
|
+
const client = await authenticateClient(ctx, body, clientStore);
|
|
142
|
+
if (!client) {
|
|
143
|
+
sendOAuthError(ctx, 401, "invalid_client", "client authentication failed");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (!onRefreshToken) {
|
|
147
|
+
sendOAuthError(ctx, 400, "unsupported_grant_type", "refresh_token grant is not supported");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const refreshToken = asString(body.refresh_token);
|
|
151
|
+
if (!refreshToken) {
|
|
152
|
+
sendOAuthError(ctx, 400, "invalid_request", "refresh_token is required");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const result = await onRefreshToken({
|
|
156
|
+
refreshToken,
|
|
157
|
+
client,
|
|
158
|
+
scopes: parseScopes(body.scope)
|
|
159
|
+
});
|
|
160
|
+
if (!result) {
|
|
161
|
+
sendOAuthError(ctx, 400, "invalid_grant", "invalid refresh token");
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
ctx.body = buildTokenResponse(await issueTokens({
|
|
165
|
+
subject: result.subject,
|
|
166
|
+
scopes: result.scopes,
|
|
167
|
+
client
|
|
168
|
+
}), result.scopes);
|
|
169
|
+
};
|
|
170
|
+
/**
|
|
171
|
+
* Handles the authorization request after `client_id` and `redirect_uri` have
|
|
172
|
+
* been validated: enforces PKCE, runs the consent hook, and issues a code.
|
|
173
|
+
*/
|
|
174
|
+
var handleAuthorize = async (ctx, options, redirectUri) => {
|
|
175
|
+
const {
|
|
176
|
+
clientStore,
|
|
177
|
+
authCodeStore,
|
|
178
|
+
onAuthorize
|
|
179
|
+
} = options;
|
|
180
|
+
const ttl = options.authorizationCodeTtl ?? 600;
|
|
181
|
+
const clientId = asString(ctx.query.client_id);
|
|
182
|
+
const client = await clientStore.get(clientId);
|
|
183
|
+
const state = asString(ctx.query.state);
|
|
184
|
+
const redirectError = (error, description) => {
|
|
185
|
+
const url = new URL(redirectUri);
|
|
186
|
+
url.searchParams.set("error", error);
|
|
187
|
+
url.searchParams.set("error_description", description);
|
|
188
|
+
if (state) url.searchParams.set("state", state);
|
|
189
|
+
ctx.redirect(url.toString());
|
|
190
|
+
};
|
|
191
|
+
if (asString(ctx.query.response_type) !== "code") {
|
|
192
|
+
redirectError("unsupported_response_type", "response_type must be code");
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const codeChallenge = asString(ctx.query.code_challenge);
|
|
196
|
+
if (!codeChallenge) {
|
|
197
|
+
redirectError("invalid_request", "code_challenge is required (PKCE)");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const codeChallengeMethod = asString(ctx.query.code_challenge_method) ?? "plain";
|
|
201
|
+
if (codeChallengeMethod !== "S256") {
|
|
202
|
+
redirectError("invalid_request", "code_challenge_method must be S256");
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const scopes = parseScopes(ctx.query.scope);
|
|
206
|
+
const result = await onAuthorize({
|
|
207
|
+
ctx,
|
|
208
|
+
client,
|
|
209
|
+
request: {
|
|
210
|
+
clientId,
|
|
211
|
+
redirectUri,
|
|
212
|
+
scopes,
|
|
213
|
+
state,
|
|
214
|
+
codeChallenge,
|
|
215
|
+
codeChallengeMethod
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
if (!result.approved) return;
|
|
219
|
+
const grantedScopes = result.scopes ?? scopes;
|
|
220
|
+
const code = generateToken();
|
|
221
|
+
await authCodeStore.save({
|
|
222
|
+
code,
|
|
223
|
+
clientId,
|
|
224
|
+
redirectUri,
|
|
225
|
+
codeChallenge,
|
|
226
|
+
scopes: grantedScopes,
|
|
227
|
+
subject: result.subject,
|
|
228
|
+
expiresAt: Date.now() + ttl * 1e3
|
|
229
|
+
});
|
|
230
|
+
const url = new URL(redirectUri);
|
|
231
|
+
url.searchParams.set("code", code);
|
|
232
|
+
if (state) url.searchParams.set("state", state);
|
|
233
|
+
ctx.redirect(url.toString());
|
|
234
|
+
};
|
|
235
|
+
/** Handles Dynamic Client Registration (RFC 7591) requests. */
|
|
236
|
+
var handleRegister = async (ctx, clientStore) => {
|
|
237
|
+
const metadata = ctx.request.body ?? {};
|
|
238
|
+
const redirectUris = metadata.redirect_uris;
|
|
239
|
+
if (!Array.isArray(redirectUris) || redirectUris.length === 0 || !redirectUris.every(uri => {
|
|
240
|
+
return typeof uri === "string";
|
|
241
|
+
})) {
|
|
242
|
+
sendOAuthError(ctx, 400, "invalid_redirect_uri", "redirect_uris is required and must be an array of strings");
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const tokenAuthMethod = asString(metadata.token_endpoint_auth_method) ?? "client_secret_basic";
|
|
246
|
+
const isPublic = tokenAuthMethod === "none";
|
|
247
|
+
const client = {
|
|
248
|
+
...metadata,
|
|
249
|
+
client_id: generateToken(16),
|
|
250
|
+
redirect_uris: redirectUris,
|
|
251
|
+
token_endpoint_auth_method: tokenAuthMethod,
|
|
252
|
+
grant_types: metadata.grant_types ?? ["authorization_code", "refresh_token"],
|
|
253
|
+
response_types: metadata.response_types ?? ["code"],
|
|
254
|
+
client_id_issued_at: Math.floor(Date.now() / 1e3)
|
|
255
|
+
};
|
|
256
|
+
if (!isPublic) {
|
|
257
|
+
client.client_secret = generateToken(32);
|
|
258
|
+
client.client_secret_expires_at = 0;
|
|
259
|
+
}
|
|
260
|
+
await clientStore.register(client);
|
|
261
|
+
ctx.status = 201;
|
|
262
|
+
ctx.body = client;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
//#endregion
|
|
266
|
+
//#region src/authServer.ts
|
|
267
|
+
/**
|
|
268
|
+
* Creates transport-agnostic OAuth 2.1 Authorization Server primitives for MCP.
|
|
269
|
+
*
|
|
270
|
+
* Mounts the authorization endpoint (`/authorize`, PKCE S256 required), token
|
|
271
|
+
* endpoint (`/token`, `authorization_code` + `refresh_token` grants), Dynamic
|
|
272
|
+
* Client Registration (`/register`, RFC 7591), and AS metadata discovery
|
|
273
|
+
* (`/.well-known/oauth-authorization-server`, RFC 8414). When `resource` is
|
|
274
|
+
* set, it also serves the protected-resource metadata (RFC 9728).
|
|
275
|
+
*
|
|
276
|
+
* ttoss owns only the protocol mechanics — the app supplies its own stores,
|
|
277
|
+
* token signing/verification, and login/consent UI through the option hooks, so
|
|
278
|
+
* the user model, JWT/IAM, and authentication never leave the consuming app.
|
|
279
|
+
*
|
|
280
|
+
* @param options - Authorization server configuration and pluggable hooks.
|
|
281
|
+
* @returns A Koa `Router` exposing `routes()` / `allowedMethods()`.
|
|
282
|
+
*
|
|
283
|
+
* @example
|
|
284
|
+
* ```typescript
|
|
285
|
+
* import { App, bodyParser } from '@ttoss/http-server';
|
|
286
|
+
* import { createMcpAuthServer } from '@ttoss/http-server-mcp';
|
|
287
|
+
*
|
|
288
|
+
* const authServer = createMcpAuthServer({
|
|
289
|
+
* issuer: 'https://api.soat.dev',
|
|
290
|
+
* clientStore,
|
|
291
|
+
* authCodeStore,
|
|
292
|
+
* issueTokens: async ({ subject, scopes }) => ({
|
|
293
|
+
* accessToken: signJwt({ sub: subject, scope: scopes.join(' ') }),
|
|
294
|
+
* refreshToken: createRefreshToken(subject),
|
|
295
|
+
* expiresIn: 3600,
|
|
296
|
+
* }),
|
|
297
|
+
* onAuthorize: async ({ ctx }) => {
|
|
298
|
+
* const session = await getSession(ctx);
|
|
299
|
+
* if (!session) {
|
|
300
|
+
* ctx.redirect('/login');
|
|
301
|
+
* return { approved: false };
|
|
302
|
+
* }
|
|
303
|
+
* return { approved: true, subject: session.userId };
|
|
304
|
+
* },
|
|
305
|
+
* scopesSupported: ['mcp:access'],
|
|
306
|
+
* });
|
|
307
|
+
*
|
|
308
|
+
* const app = new App();
|
|
309
|
+
* app.use(bodyParser());
|
|
310
|
+
* app.use(authServer.routes());
|
|
311
|
+
* ```
|
|
312
|
+
*/
|
|
313
|
+
var createMcpAuthServer = options => {
|
|
314
|
+
const {
|
|
315
|
+
issuer,
|
|
316
|
+
clientStore,
|
|
317
|
+
scopesSupported,
|
|
318
|
+
resource
|
|
319
|
+
} = options;
|
|
320
|
+
const authorizePath = options.endpoints?.authorize ?? "/authorize";
|
|
321
|
+
const tokenPath = options.endpoints?.token ?? "/token";
|
|
322
|
+
const registerPath = options.endpoints?.register ?? "/register";
|
|
323
|
+
const router = new _ttoss_http_server.Router();
|
|
324
|
+
router.get("/.well-known/oauth-authorization-server", ctx => {
|
|
325
|
+
ctx.body = {
|
|
326
|
+
issuer,
|
|
327
|
+
authorization_endpoint: joinUrl(issuer, authorizePath),
|
|
328
|
+
token_endpoint: joinUrl(issuer, tokenPath),
|
|
329
|
+
registration_endpoint: joinUrl(issuer, registerPath),
|
|
330
|
+
response_types_supported: ["code"],
|
|
331
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
332
|
+
code_challenge_methods_supported: ["S256"],
|
|
333
|
+
token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
|
|
334
|
+
...(scopesSupported ? {
|
|
335
|
+
scopes_supported: scopesSupported
|
|
336
|
+
} : {})
|
|
337
|
+
};
|
|
338
|
+
});
|
|
339
|
+
if (resource) router.get("/.well-known/oauth-protected-resource", ctx => {
|
|
340
|
+
ctx.body = {
|
|
341
|
+
resource,
|
|
342
|
+
authorization_servers: [issuer]
|
|
343
|
+
};
|
|
344
|
+
});
|
|
345
|
+
router.get(authorizePath, async ctx => {
|
|
346
|
+
const clientId = asString(ctx.query.client_id);
|
|
347
|
+
if (!clientId) {
|
|
348
|
+
sendOAuthError(ctx, 400, "invalid_request", "client_id is required");
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const client = await clientStore.get(clientId);
|
|
352
|
+
if (!client) {
|
|
353
|
+
sendOAuthError(ctx, 400, "invalid_client", "unknown client_id");
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const redirectUri = asString(ctx.query.redirect_uri);
|
|
357
|
+
if (!redirectUri || !client.redirect_uris.includes(redirectUri)) {
|
|
358
|
+
sendOAuthError(ctx, 400, "invalid_request", "redirect_uri is missing or not registered for this client");
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
await handleAuthorize(ctx, options, redirectUri);
|
|
362
|
+
});
|
|
363
|
+
router.post(tokenPath, async ctx => {
|
|
364
|
+
const body = ctx.request.body ?? {};
|
|
365
|
+
const grantType = asString(body.grant_type);
|
|
366
|
+
if (grantType === "authorization_code") {
|
|
367
|
+
await handleAuthorizationCodeGrant(ctx, body, options);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (grantType === "refresh_token") {
|
|
371
|
+
await handleRefreshTokenGrant(ctx, body, options);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
sendOAuthError(ctx, 400, "unsupported_grant_type", "grant_type must be authorization_code or refresh_token");
|
|
375
|
+
});
|
|
376
|
+
router.post(registerPath, async ctx => {
|
|
377
|
+
await handleRegister(ctx, clientStore);
|
|
378
|
+
});
|
|
379
|
+
return router;
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
//#endregion
|
|
12
383
|
//#region src/index.ts
|
|
13
384
|
var requestContextStore = new node_async_hooks.AsyncLocalStorage();
|
|
14
385
|
/**
|
|
@@ -380,6 +751,62 @@ var registerToolFromSchema = (server, params) => {
|
|
|
380
751
|
});
|
|
381
752
|
}
|
|
382
753
|
};
|
|
754
|
+
/**
|
|
755
|
+
* Returns the `WWW-Authenticate` header value for a 401 response on a
|
|
756
|
+
* protected resource, following the MCP auth spec requirement that
|
|
757
|
+
* unauthorized responses advertise the resource metadata URL so MCP
|
|
758
|
+
* clients can bootstrap OAuth discovery.
|
|
759
|
+
*
|
|
760
|
+
* Use this in your own auth middleware when you are not using the built-in
|
|
761
|
+
* `auth` option on `createMcpRouter`.
|
|
762
|
+
*
|
|
763
|
+
* @example
|
|
764
|
+
* ```typescript
|
|
765
|
+
* import { getWwwAuthenticateHeader } from '@ttoss/http-server-mcp';
|
|
766
|
+
*
|
|
767
|
+
* // Inside a Koa middleware
|
|
768
|
+
* ctx.status = 401;
|
|
769
|
+
* ctx.set('WWW-Authenticate', getWwwAuthenticateHeader({ resource: 'https://mcp.example.com' }));
|
|
770
|
+
* ```
|
|
771
|
+
*/
|
|
772
|
+
var getWwwAuthenticateHeader = args => {
|
|
773
|
+
return `Bearer resource_metadata="${`${args.resource.replace(/\/$/, "")}/.well-known/oauth-protected-resource`}"`;
|
|
774
|
+
};
|
|
775
|
+
/**
|
|
776
|
+
* Creates a standalone Koa middleware that serves
|
|
777
|
+
* `GET /.well-known/oauth-protected-resource` (RFC 9728) without requiring
|
|
778
|
+
* the built-in `auth` option on `createMcpRouter`.
|
|
779
|
+
*
|
|
780
|
+
* Mount this **before** your own auth middleware so the discovery endpoint
|
|
781
|
+
* remains unauthenticated (MCP clients fetch it before they have a token).
|
|
782
|
+
*
|
|
783
|
+
* @example
|
|
784
|
+
* ```typescript
|
|
785
|
+
* import Koa from 'koa';
|
|
786
|
+
* import { createProtectedResourceMetadataMiddleware } from '@ttoss/http-server-mcp';
|
|
787
|
+
*
|
|
788
|
+
* const app = new Koa();
|
|
789
|
+
* app.use(
|
|
790
|
+
* createProtectedResourceMetadataMiddleware({
|
|
791
|
+
* resource: 'https://mcp.example.com',
|
|
792
|
+
* authorizationServers: ['https://api.example.com'],
|
|
793
|
+
* })
|
|
794
|
+
* );
|
|
795
|
+
* app.use(myOwnAuthMiddleware);
|
|
796
|
+
* ```
|
|
797
|
+
*/
|
|
798
|
+
var createProtectedResourceMetadataMiddleware = args => {
|
|
799
|
+
return async (ctx, next) => {
|
|
800
|
+
if (ctx.method === "GET" && ctx.path === "/.well-known/oauth-protected-resource") {
|
|
801
|
+
ctx.body = {
|
|
802
|
+
resource: args.resource,
|
|
803
|
+
authorization_servers: args.authorizationServers
|
|
804
|
+
};
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
await next();
|
|
808
|
+
};
|
|
809
|
+
};
|
|
383
810
|
|
|
384
811
|
//#endregion
|
|
385
812
|
Object.defineProperty(exports, 'McpServer', {
|
|
@@ -390,8 +817,11 @@ Object.defineProperty(exports, 'McpServer', {
|
|
|
390
817
|
});
|
|
391
818
|
exports.apiCall = apiCall;
|
|
392
819
|
exports.checkScopes = checkScopes;
|
|
820
|
+
exports.createMcpAuthServer = createMcpAuthServer;
|
|
393
821
|
exports.createMcpRouter = createMcpRouter;
|
|
822
|
+
exports.createProtectedResourceMetadataMiddleware = createProtectedResourceMetadataMiddleware;
|
|
394
823
|
exports.getIdentity = getIdentity;
|
|
824
|
+
exports.getWwwAuthenticateHeader = getWwwAuthenticateHeader;
|
|
395
825
|
exports.registerToolFromSchema = registerToolFromSchema;
|
|
396
826
|
Object.defineProperty(exports, 'z', {
|
|
397
827
|
enumerable: true,
|