@n24q02m/hull 0.4.0 → 0.5.0-beta.1

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 (169) hide show
  1. package/README.md +47 -16
  2. package/build/cli/build-cli.d.ts +5 -6
  3. package/build/cli/build-cli.d.ts.map +1 -1
  4. package/build/cli/build-cli.js +4 -61
  5. package/build/cli/build-cli.js.map +1 -1
  6. package/build/crypto/index.d.ts +1 -1
  7. package/build/crypto/index.d.ts.map +1 -1
  8. package/build/crypto/index.js +1 -1
  9. package/build/crypto/index.js.map +1 -1
  10. package/build/crypto/kdf.d.ts +0 -13
  11. package/build/crypto/kdf.d.ts.map +1 -1
  12. package/build/crypto/kdf.js +3 -18
  13. package/build/crypto/kdf.js.map +1 -1
  14. package/build/index.d.ts +1 -11
  15. package/build/index.d.ts.map +1 -1
  16. package/build/index.js +4 -16
  17. package/build/index.js.map +1 -1
  18. package/build/storage/account-registry.d.ts +53 -0
  19. package/build/storage/account-registry.d.ts.map +1 -0
  20. package/build/storage/account-registry.js +148 -0
  21. package/build/storage/account-registry.js.map +1 -0
  22. package/build/storage/backends.d.ts +0 -26
  23. package/build/storage/backends.d.ts.map +1 -1
  24. package/build/storage/backends.js +0 -65
  25. package/build/storage/backends.js.map +1 -1
  26. package/build/storage/config-file.d.ts +2 -19
  27. package/build/storage/config-file.d.ts.map +1 -1
  28. package/build/storage/config-file.js +2 -23
  29. package/build/storage/config-file.js.map +1 -1
  30. package/build/storage/credential-store.d.ts +1 -9
  31. package/build/storage/credential-store.d.ts.map +1 -1
  32. package/build/storage/credential-store.js +4 -16
  33. package/build/storage/credential-store.js.map +1 -1
  34. package/build/storage/data-dir.d.ts +2 -2
  35. package/build/storage/data-dir.js +2 -2
  36. package/build/storage/encryption.d.ts.map +1 -1
  37. package/build/storage/encryption.js +4 -0
  38. package/build/storage/encryption.js.map +1 -1
  39. package/build/storage/index.d.ts +5 -3
  40. package/build/storage/index.d.ts.map +1 -1
  41. package/build/storage/index.js +5 -3
  42. package/build/storage/index.js.map +1 -1
  43. package/build/storage/key-pool-crypto.d.ts +44 -0
  44. package/build/storage/key-pool-crypto.d.ts.map +1 -0
  45. package/build/storage/key-pool-crypto.js +132 -0
  46. package/build/storage/key-pool-crypto.js.map +1 -0
  47. package/build/storage/key-pool-store.d.ts +134 -0
  48. package/build/storage/key-pool-store.d.ts.map +1 -0
  49. package/build/storage/key-pool-store.js +470 -0
  50. package/build/storage/key-pool-store.js.map +1 -0
  51. package/build/storage/key-pool.d.ts +172 -0
  52. package/build/storage/key-pool.d.ts.map +1 -0
  53. package/build/storage/key-pool.js +374 -0
  54. package/build/storage/key-pool.js.map +1 -0
  55. package/build/storage/resolver.js +1 -1
  56. package/build/storage/resolver.js.map +1 -1
  57. package/build/token-auth/index.d.ts +1 -1
  58. package/build/token-auth/index.js +1 -1
  59. package/build/transport/index.d.ts +1 -2
  60. package/build/transport/index.d.ts.map +1 -1
  61. package/build/transport/index.js +1 -2
  62. package/build/transport/index.js.map +1 -1
  63. package/build/transport/local-server.d.ts +12 -128
  64. package/build/transport/local-server.d.ts.map +1 -1
  65. package/build/transport/local-server.js +20 -262
  66. package/build/transport/local-server.js.map +1 -1
  67. package/build/transport/streamable-http.d.ts +6 -7
  68. package/build/transport/streamable-http.d.ts.map +1 -1
  69. package/build/transport/streamable-http.js +6 -6
  70. package/build/transport/streamable-http.js.map +1 -1
  71. package/build/util/browser.d.ts.map +1 -0
  72. package/build/{relay → util}/browser.js +2 -2
  73. package/build/util/browser.js.map +1 -0
  74. package/package.json +4 -21
  75. package/build/auth/credential-form.d.ts +0 -163
  76. package/build/auth/credential-form.d.ts.map +0 -1
  77. package/build/auth/credential-form.js +0 -1869
  78. package/build/auth/credential-form.js.map +0 -1
  79. package/build/auth/delegated-oauth-app.d.ts +0 -90
  80. package/build/auth/delegated-oauth-app.d.ts.map +0 -1
  81. package/build/auth/delegated-oauth-app.js +0 -881
  82. package/build/auth/delegated-oauth-app.js.map +0 -1
  83. package/build/auth/index.d.ts +0 -12
  84. package/build/auth/index.d.ts.map +0 -1
  85. package/build/auth/index.js +0 -12
  86. package/build/auth/index.js.map +0 -1
  87. package/build/auth/local-oauth-app.d.ts +0 -152
  88. package/build/auth/local-oauth-app.d.ts.map +0 -1
  89. package/build/auth/local-oauth-app.js +0 -975
  90. package/build/auth/local-oauth-app.js.map +0 -1
  91. package/build/auth/relay-login.d.ts +0 -46
  92. package/build/auth/relay-login.d.ts.map +0 -1
  93. package/build/auth/relay-login.js +0 -197
  94. package/build/auth/relay-login.js.map +0 -1
  95. package/build/auth/router.d.ts +0 -18
  96. package/build/auth/router.d.ts.map +0 -1
  97. package/build/auth/router.js +0 -62
  98. package/build/auth/router.js.map +0 -1
  99. package/build/auth/session-store.d.ts +0 -49
  100. package/build/auth/session-store.d.ts.map +0 -1
  101. package/build/auth/session-store.js +0 -94
  102. package/build/auth/session-store.js.map +0 -1
  103. package/build/auth/stable-sub.d.ts +0 -27
  104. package/build/auth/stable-sub.d.ts.map +0 -1
  105. package/build/auth/stable-sub.js +0 -38
  106. package/build/auth/stable-sub.js.map +0 -1
  107. package/build/auth/well-known.d.ts +0 -4
  108. package/build/auth/well-known.d.ts.map +0 -1
  109. package/build/auth/well-known.js +0 -26
  110. package/build/auth/well-known.js.map +0 -1
  111. package/build/oauth/cache.d.ts +0 -22
  112. package/build/oauth/cache.d.ts.map +0 -1
  113. package/build/oauth/cache.js +0 -24
  114. package/build/oauth/cache.js.map +0 -1
  115. package/build/oauth/index.d.ts +0 -6
  116. package/build/oauth/index.d.ts.map +0 -1
  117. package/build/oauth/index.js +0 -6
  118. package/build/oauth/index.js.map +0 -1
  119. package/build/oauth/jwt-issuer.d.ts +0 -87
  120. package/build/oauth/jwt-issuer.d.ts.map +0 -1
  121. package/build/oauth/jwt-issuer.js +0 -205
  122. package/build/oauth/jwt-issuer.js.map +0 -1
  123. package/build/oauth/pkce.d.ts +0 -10
  124. package/build/oauth/pkce.d.ts.map +0 -1
  125. package/build/oauth/pkce.js +0 -34
  126. package/build/oauth/pkce.js.map +0 -1
  127. package/build/oauth/provider.d.ts +0 -52
  128. package/build/oauth/provider.d.ts.map +0 -1
  129. package/build/oauth/provider.js +0 -98
  130. package/build/oauth/provider.js.map +0 -1
  131. package/build/oauth/user-store.d.ts +0 -25
  132. package/build/oauth/user-store.d.ts.map +0 -1
  133. package/build/oauth/user-store.js +0 -85
  134. package/build/oauth/user-store.js.map +0 -1
  135. package/build/relay/browser.d.ts.map +0 -1
  136. package/build/relay/browser.js.map +0 -1
  137. package/build/relay/client.d.ts +0 -36
  138. package/build/relay/client.d.ts.map +0 -1
  139. package/build/relay/client.js +0 -135
  140. package/build/relay/client.js.map +0 -1
  141. package/build/relay/session.d.ts +0 -12
  142. package/build/relay/session.d.ts.map +0 -1
  143. package/build/relay/session.js +0 -55
  144. package/build/relay/session.js.map +0 -1
  145. package/build/relay/tool-helpers.d.ts +0 -85
  146. package/build/relay/tool-helpers.d.ts.map +0 -1
  147. package/build/relay/tool-helpers.js +0 -103
  148. package/build/relay/tool-helpers.js.map +0 -1
  149. package/build/relay/wordlist.d.ts +0 -2
  150. package/build/relay/wordlist.d.ts.map +0 -1
  151. package/build/relay/wordlist.js +0 -7782
  152. package/build/relay/wordlist.js.map +0 -1
  153. package/build/schema/types.d.ts +0 -67
  154. package/build/schema/types.d.ts.map +0 -1
  155. package/build/schema/types.js +0 -2
  156. package/build/schema/types.js.map +0 -1
  157. package/build/storage/mode.d.ts +0 -23
  158. package/build/storage/mode.d.ts.map +0 -1
  159. package/build/storage/mode.js +0 -46
  160. package/build/storage/mode.js.map +0 -1
  161. package/build/storage/session-lock.d.ts +0 -19
  162. package/build/storage/session-lock.d.ts.map +0 -1
  163. package/build/storage/session-lock.js +0 -105
  164. package/build/storage/session-lock.js.map +0 -1
  165. package/build/transport/oauth-middleware.d.ts +0 -45
  166. package/build/transport/oauth-middleware.d.ts.map +0 -1
  167. package/build/transport/oauth-middleware.js +0 -86
  168. package/build/transport/oauth-middleware.js.map +0 -1
  169. /package/build/{relay → util}/browser.d.ts +0 -0
@@ -1,881 +0,0 @@
1
- /**
2
- * Delegated OAuth 2.1 Authorization Server as an HTTP request handler.
3
- *
4
- * Unified abstraction for upstream OAuth providers. Fronts two upstream
5
- * flows behind a single local OAuth 2.1 PKCE facade for the MCP client:
6
- *
7
- * - ``redirect`` flow -- classic authorization_code redirect (Notion,
8
- * Grafana, GitHub, ...). The local server proxies ``/authorize`` to the
9
- * upstream authorize endpoint, handles the callback, exchanges the
10
- * upstream code for upstream tokens via ``upstream.tokenUrl``, forwards
11
- * the tokens to the consumer via ``onTokenReceived``, then finishes the
12
- * local PKCE exchange so the MCP client receives a local JWT.
13
- * - ``device_code`` flow (RFC 8628) -- used by GDrive, Outlook, etc. The
14
- * local server initiates the upstream device authorization, renders a
15
- * page showing ``user_code`` + ``verification_url``, runs a background
16
- * polling task on ``upstream.tokenUrl`` until the upstream grants a
17
- * token, invokes ``onTokenReceived``, and signals completion via the
18
- * setup-status endpoint.
19
- *
20
- * This is a TypeScript port of core-py's ``delegated_oauth_app.py``.
21
- * Behavior, protocol, and TTL constants are kept identical for parity.
22
- */
23
- import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
24
- import { JWTIssuer } from '../oauth/jwt-issuer.js';
25
- import { configureRelayLogin, createRelayLoginMiddleware, loginGetHandler, loginPostHandler } from './relay-login.js';
26
- import { createRouter, htmlResponse, jsonResponse, parseFormBody, parseJsonBody } from './router.js';
27
- import { createSessionStore } from './session-store.js';
28
- import { authorizationServerMetadata, protectedResourceMetadata } from './well-known.js';
29
- const AUTH_CODE_TTL_S = 600;
30
- const SESSION_TTL_S = 600;
31
- function s256Verify(codeVerifier, codeChallenge) {
32
- const computed = createHash('sha256').update(codeVerifier, 'ascii').digest('base64url');
33
- const computedBuf = Buffer.from(computed, 'ascii');
34
- const challengeBuf = Buffer.from(codeChallenge, 'ascii');
35
- const isLengthEqual = computedBuf.length === challengeBuf.length;
36
- const compareBuf = isLengthEqual ? challengeBuf : computedBuf;
37
- try {
38
- return timingSafeEqual(computedBuf, compareBuf) && isLengthEqual;
39
- }
40
- catch {
41
- return false;
42
- }
43
- }
44
- /**
45
- * Mutates ``body`` to include client credentials per the configured auth
46
- * method and returns any HTTP headers required to authenticate the token
47
- * request with the upstream endpoint.
48
- *
49
- * Default is ``client_secret_basic`` (HTTP Basic) per RFC 6749 §2.3.1 which
50
- * says clients MUST support basic and MAY support post. Notion / GitHub /
51
- * Microsoft identity platform reject ``client_secret_post`` with
52
- * ``invalid_client``; Google/Slack accept both. Public clients (no secret)
53
- * always use ``client_id`` in the body regardless of method.
54
- */
55
- function buildClientAuth(body, upstream) {
56
- const method = upstream.tokenEndpointAuthMethod ?? 'client_secret_basic';
57
- if (!upstream.clientSecret) {
58
- body.set('client_id', upstream.clientId);
59
- return {};
60
- }
61
- if (method === 'client_secret_post') {
62
- body.set('client_id', upstream.clientId);
63
- body.set('client_secret', upstream.clientSecret);
64
- return {};
65
- }
66
- const encoded = Buffer.from(`${upstream.clientId}:${upstream.clientSecret}`, 'utf8').toString('base64');
67
- return { Authorization: `Basic ${encoded}` };
68
- }
69
- /**
70
- * Derive the public base URL of this request. See ``local-oauth-app.ts`` for
71
- * the resolution order; this function is the delegated-flow twin and must
72
- * stay in lock-step so both well-known documents agree on the issuer.
73
- */
74
- function getBaseUrl(req) {
75
- const publicUrl = process.env.PUBLIC_URL;
76
- if (publicUrl !== undefined && publicUrl.length > 0) {
77
- return publicUrl.replace(/\/+$/, '');
78
- }
79
- const host = req.headers.host ?? 'localhost';
80
- const encrypted = req.socket.encrypted === true;
81
- const forwardedProto = req.headers['x-forwarded-proto'];
82
- let protocol = encrypted ? 'https' : 'http';
83
- if (typeof forwardedProto === 'string' && forwardedProto.length > 0) {
84
- const commaIdx = forwardedProto.indexOf(',');
85
- protocol = (commaIdx !== -1 ? forwardedProto.substring(0, commaIdx) : forwardedProto).trim();
86
- }
87
- return `${protocol}://${host}`;
88
- }
89
- function escapeHtml(value) {
90
- return value
91
- .replace(/&/g, '&')
92
- .replace(/</g, '&lt;')
93
- .replace(/>/g, '&gt;')
94
- .replace(/"/g, '&quot;')
95
- .replace(/'/g, '&#39;');
96
- }
97
- function renderDeviceCodePage(opts) {
98
- const safeName = escapeHtml(opts.serverName);
99
- const safeCode = escapeHtml(opts.userCode);
100
- const safeUrl = escapeHtml(opts.verificationUrl);
101
- return `<!DOCTYPE html>
102
- <html lang="en">
103
- <head>
104
- <meta charset="utf-8" />
105
- <title>Authorize ${safeName}</title>
106
- <style>
107
- body { font-family: system-ui, sans-serif; background: #0d0d0d; color: #eee;
108
- display: flex; align-items: center; justify-content: center;
109
- min-height: 100vh; margin: 0; }
110
- .card { background: #181818; padding: 2rem 3rem; border-radius: 12px;
111
- border: 1px solid #333; max-width: 480px; text-align: center; }
112
- h1 { margin-top: 0; }
113
- .code { font-size: 2rem; font-family: ui-monospace, monospace;
114
- letter-spacing: 0.25em; padding: 1rem 1.5rem; background: #000;
115
- border-radius: 8px; border: 1px solid #444; margin: 1.5rem 0;
116
- user-select: all; cursor: copy; }
117
- a { color: #4ea1ff; }
118
- .status { margin-top: 1.5rem; color: #888; font-size: 0.9rem; }
119
- </style>
120
- </head>
121
- <body>
122
- <div class="card">
123
- <h1>Authorize ${safeName}</h1>
124
- <p>Visit the URL below and enter this code:</p>
125
- <div class="code" title="Click to select">${safeCode}</div>
126
- <p><a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${safeUrl}</a></p>
127
- <p class="status" id="status" role="alert" aria-live="polite" aria-atomic="true">Waiting for you to approve...</p>
128
- </div>
129
- <script>
130
- async function poll() {
131
- try {
132
- const r = await fetch('/setup-status');
133
- const j = await r.json();
134
- const s = j[Object.keys(j)[0]] || 'idle';
135
- if (s === 'complete') {
136
- document.getElementById('status').textContent = 'Authorized! You can close this window.';
137
- return;
138
- }
139
- if (s === 'error') {
140
- document.getElementById('status').textContent = 'Authorization failed. Please restart.';
141
- return;
142
- }
143
- } catch (e) {}
144
- setTimeout(poll, 2000);
145
- }
146
- poll();
147
- </script>
148
- </body>
149
- </html>`;
150
- }
151
- export async function createDelegatedOAuthApp(options) {
152
- if (options.flow === 'redirect' && !options.upstream.authorizeUrl) {
153
- throw new Error('authorizeUrl is required for redirect flow');
154
- }
155
- if (options.flow === 'device_code' && !options.upstream.deviceAuthUrl) {
156
- throw new Error('deviceAuthUrl is required for device_code flow');
157
- }
158
- const signingSecret = process.env.MCP_JWT_SIGNING_SECRET ?? process.env.CREDENTIAL_SECRET ?? null;
159
- const jwtIssuer = options.jwtIssuer ?? new JWTIssuer(options.serverName, undefined, signingSecret);
160
- await jwtIssuer.init();
161
- const pendingSessions = createSessionStore(options.sessionKv, SESSION_TTL_S);
162
- const authCodes = createSessionStore(options.sessionKv, AUTH_CODE_TTL_S);
163
- const setupStatus = { [options.serverName]: 'idle' };
164
- // Each entry: AbortController for the background poll loop.
165
- const pollControllers = new Set();
166
- const callbackPath = options.upstream.callbackPath ?? '/callback';
167
- // Edge auth password gate. When ``MCP_RELAY_PASSWORD`` is set, /authorize
168
- // is fronted by a cookie-session middleware. Empty password disables the
169
- // gate (single-user dev). Mirrors createLocalOAuthApp wiring; see spec
170
- // ``2026-05-01-stdio-pure-http-multiuser §4.2.1 + §5.1.2.1`` and PR #158.
171
- // Configured here (not module-scope) so multi-app test setups don't leak
172
- // state across apps; the relay-login module itself keeps a single shared
173
- // password string, intentional because in deploy each container hosts one
174
- // app. /token, /register, /setup-status, /callback, /.well-known/* stay
175
- // ungated by design — they are machine endpoints / mid-OAuth callbacks
176
- // that cannot be password-gated without breaking the upstream protocol.
177
- const relayPassword = process.env.MCP_RELAY_PASSWORD ?? '';
178
- configureRelayLogin(relayPassword);
179
- const relayMw = createRelayLoginMiddleware({ password: relayPassword });
180
- function markSetupComplete(key) {
181
- setupStatus[key ?? options.serverName] = 'complete';
182
- }
183
- function markSetupError(key) {
184
- setupStatus[key ?? options.serverName] = 'error';
185
- }
186
- async function invokeTokenCallback(tokens) {
187
- const result = await options.onTokenReceived(tokens);
188
- return typeof result === 'string' && result.length > 0 ? result : 'local-user';
189
- }
190
- // ------------------------------------------------------------------
191
- // Redirect flow
192
- // ------------------------------------------------------------------
193
- async function authorizeRedirect(req, res) {
194
- const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
195
- const p = url.searchParams;
196
- const clientId = p.get('client_id');
197
- const redirectUri = p.get('redirect_uri');
198
- const state = p.get('state');
199
- const codeChallenge = p.get('code_challenge');
200
- const codeChallengeMethod = p.get('code_challenge_method') ?? 'S256';
201
- if (!clientId || !redirectUri || !state || !codeChallenge) {
202
- jsonResponse(res, 400, {
203
- error: 'invalid_request',
204
- error_description: 'Missing required parameters'
205
- });
206
- return;
207
- }
208
- const nonce = randomBytes(32).toString('base64url');
209
- await pendingSessions.set(nonce, {
210
- clientId,
211
- redirectUri,
212
- state,
213
- codeChallenge,
214
- codeChallengeMethod,
215
- createdAt: Date.now()
216
- });
217
- const base = getBaseUrl(req);
218
- const qs = new URLSearchParams({
219
- client_id: options.upstream.clientId,
220
- redirect_uri: `${base}${callbackPath}`,
221
- response_type: 'code',
222
- state: nonce
223
- });
224
- if (options.upstream.scopes && options.upstream.scopes.length > 0) {
225
- qs.set('scope', options.upstream.scopes.join(' '));
226
- }
227
- if (options.upstream.authorizeParams) {
228
- for (const [k, v] of Object.entries(options.upstream.authorizeParams)) {
229
- qs.set(k, v);
230
- }
231
- }
232
- const authorizeUrl = options.upstream.authorizeUrl;
233
- const separator = authorizeUrl.includes('?') ? '&' : '?';
234
- const target = `${authorizeUrl}${separator}${qs.toString()}`;
235
- res.writeHead(302, { Location: target });
236
- res.end();
237
- }
238
- async function callback(req, res) {
239
- const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
240
- const code = url.searchParams.get('code');
241
- const state = url.searchParams.get('state');
242
- if (!code || !state) {
243
- jsonResponse(res, 400, {
244
- error: 'invalid_request',
245
- error_description: 'Missing code or state'
246
- });
247
- return;
248
- }
249
- const session = await pendingSessions.get(state);
250
- if (session === undefined) {
251
- jsonResponse(res, 400, { error: 'invalid_request', error_description: 'Invalid state' });
252
- return;
253
- }
254
- await pendingSessions.delete(state);
255
- const base = getBaseUrl(req);
256
- const form = new URLSearchParams({
257
- grant_type: 'authorization_code',
258
- code,
259
- redirect_uri: `${base}${callbackPath}`
260
- });
261
- const authHeaders = buildClientAuth(form, options.upstream);
262
- let upstreamResp;
263
- try {
264
- upstreamResp = await fetch(options.upstream.tokenUrl, {
265
- method: 'POST',
266
- headers: {
267
- 'Content-Type': 'application/x-www-form-urlencoded',
268
- Accept: 'application/json',
269
- ...authHeaders
270
- },
271
- body: form.toString()
272
- });
273
- }
274
- catch {
275
- jsonResponse(res, 502, {
276
- error: 'server_error',
277
- error_description: 'Upstream token exchange failed'
278
- });
279
- return;
280
- }
281
- if (!upstreamResp.ok) {
282
- const text = await upstreamResp.text().catch(() => '');
283
- jsonResponse(res, 400, {
284
- error: 'invalid_grant',
285
- error_description: `Upstream rejected token exchange: ${text}`
286
- });
287
- return;
288
- }
289
- let tokens;
290
- try {
291
- tokens = (await upstreamResp.json());
292
- }
293
- catch {
294
- jsonResponse(res, 502, {
295
- error: 'server_error',
296
- error_description: 'Upstream returned invalid JSON'
297
- });
298
- return;
299
- }
300
- let sub;
301
- try {
302
- sub = await invokeTokenCallback(tokens);
303
- }
304
- catch {
305
- jsonResponse(res, 500, {
306
- error: 'server_error',
307
- error_description: 'Failed to persist tokens'
308
- });
309
- return;
310
- }
311
- const authCode = randomBytes(32).toString('base64url');
312
- await authCodes.set(authCode, {
313
- codeChallenge: session.codeChallenge,
314
- codeChallengeMethod: session.codeChallengeMethod,
315
- sub,
316
- createdAt: Date.now()
317
- });
318
- // RFC 9207 / SEP-2468: the authorization response to the MCP client carries
319
- // ``iss`` = the LOCAL AS issuer (``base``, already derived above), so the
320
- // client can verify the response came from the AS it started the flow with.
321
- const separator = session.redirectUri.includes('?') ? '&' : '?';
322
- const redirectUrl = `${session.redirectUri}${separator}code=${authCode}&state=${session.state}` + `&iss=${encodeURIComponent(base)}`;
323
- res.writeHead(302, { Location: redirectUrl });
324
- res.end();
325
- }
326
- // ------------------------------------------------------------------
327
- // Device code flow
328
- // ------------------------------------------------------------------
329
- async function pollDeviceToken(deviceCode, initialIntervalMs, controller, authCode) {
330
- let intervalMs = Math.max(initialIntervalMs, 0);
331
- const body = new URLSearchParams({
332
- grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
333
- device_code: deviceCode
334
- });
335
- const authHeaders = buildClientAuth(body, options.upstream);
336
- while (!controller.signal.aborted) {
337
- await new Promise((resolve) => {
338
- const t = setTimeout(resolve, intervalMs);
339
- controller.signal.addEventListener('abort', () => {
340
- clearTimeout(t);
341
- resolve();
342
- }, { once: true });
343
- });
344
- if (controller.signal.aborted)
345
- return;
346
- let resp;
347
- try {
348
- resp = await fetch(options.upstream.tokenUrl, {
349
- method: 'POST',
350
- headers: {
351
- 'Content-Type': 'application/x-www-form-urlencoded',
352
- Accept: 'application/json',
353
- ...authHeaders
354
- },
355
- body: body.toString(),
356
- signal: controller.signal
357
- });
358
- }
359
- catch {
360
- if (controller.signal.aborted)
361
- return;
362
- markSetupError();
363
- return;
364
- }
365
- if (resp.ok) {
366
- let tokens;
367
- try {
368
- tokens = (await resp.json());
369
- }
370
- catch {
371
- markSetupError();
372
- return;
373
- }
374
- let sub;
375
- try {
376
- sub = await invokeTokenCallback(tokens);
377
- }
378
- catch {
379
- markSetupError();
380
- return;
381
- }
382
- // Update pre-allocated authCode entry with real subject.
383
- const entry = await authCodes.get(authCode);
384
- if (entry) {
385
- entry.sub = sub;
386
- await authCodes.set(authCode, entry);
387
- }
388
- markSetupComplete();
389
- return;
390
- }
391
- let errBody;
392
- try {
393
- errBody = (await resp.json());
394
- }
395
- catch {
396
- markSetupError();
397
- return;
398
- }
399
- const err = errBody.error;
400
- if (err === 'authorization_pending')
401
- continue;
402
- if (err === 'slow_down') {
403
- intervalMs += 5000;
404
- continue;
405
- }
406
- markSetupError();
407
- return;
408
- }
409
- }
410
- async function authorizeDeviceCode(req, res) {
411
- const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
412
- const p = url.searchParams;
413
- const clientId = p.get('client_id');
414
- const redirectUri = p.get('redirect_uri');
415
- const state = p.get('state');
416
- const codeChallenge = p.get('code_challenge');
417
- const codeChallengeMethod = p.get('code_challenge_method') ?? 'S256';
418
- if (!clientId || !redirectUri || !state || !codeChallenge) {
419
- jsonResponse(res, 400, {
420
- error: 'invalid_request',
421
- error_description: 'Missing required parameters'
422
- });
423
- return;
424
- }
425
- // Initiate upstream device authorization.
426
- const body = new URLSearchParams({ client_id: options.upstream.clientId });
427
- if (options.upstream.scopes && options.upstream.scopes.length > 0) {
428
- body.set('scope', options.upstream.scopes.join(' '));
429
- }
430
- let upstreamResp;
431
- try {
432
- upstreamResp = await fetch(options.upstream.deviceAuthUrl, {
433
- method: 'POST',
434
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
435
- body: body.toString()
436
- });
437
- }
438
- catch {
439
- jsonResponse(res, 502, {
440
- error: 'server_error',
441
- error_description: 'Upstream device auth failed'
442
- });
443
- return;
444
- }
445
- if (!upstreamResp.ok) {
446
- const text = await upstreamResp.text().catch(() => '');
447
- jsonResponse(res, 502, {
448
- error: 'server_error',
449
- error_description: `Upstream device auth rejected: ${text}`
450
- });
451
- return;
452
- }
453
- let device;
454
- try {
455
- device = (await upstreamResp.json());
456
- }
457
- catch {
458
- jsonResponse(res, 502, {
459
- error: 'server_error',
460
- error_description: 'Upstream device auth returned invalid JSON'
461
- });
462
- return;
463
- }
464
- const deviceCode = typeof device.device_code === 'string' ? device.device_code : undefined;
465
- const userCode = typeof device.user_code === 'string' ? device.user_code : undefined;
466
- const verificationUrl = typeof device.verification_url === 'string'
467
- ? device.verification_url
468
- : typeof device.verification_uri === 'string'
469
- ? device.verification_uri
470
- : undefined;
471
- const intervalSecs = typeof device.interval === 'number'
472
- ? device.interval
473
- : Math.max((options.upstream.pollIntervalMs ?? 5000) / 1000, 1);
474
- if (!deviceCode || !userCode || !verificationUrl) {
475
- jsonResponse(res, 502, {
476
- error: 'server_error',
477
- error_description: 'Upstream device auth response missing fields'
478
- });
479
- return;
480
- }
481
- // Pre-allocate auth code so /token can complete once polling succeeds.
482
- // `sub` is a placeholder; pollDeviceToken updates it after
483
- // invokeTokenCallback returns the real subject id.
484
- const authCode = randomBytes(32).toString('base64url');
485
- await authCodes.set(authCode, {
486
- codeChallenge,
487
- codeChallengeMethod,
488
- sub: 'local-user',
489
- createdAt: Date.now()
490
- });
491
- setupStatus[options.serverName] = 'pending';
492
- const controller = new AbortController();
493
- pollControllers.add(controller);
494
- // Run in background; intentionally not awaited.
495
- pollDeviceToken(deviceCode, intervalSecs * 1000, controller, authCode).finally(() => {
496
- pollControllers.delete(controller);
497
- });
498
- htmlResponse(res, 200, renderDeviceCodePage({
499
- serverName: options.serverName,
500
- userCode,
501
- verificationUrl
502
- }));
503
- }
504
- // ------------------------------------------------------------------
505
- // Shared endpoints
506
- // ------------------------------------------------------------------
507
- async function authorize(req, res) {
508
- if (options.flow === 'redirect') {
509
- await authorizeRedirect(req, res);
510
- return;
511
- }
512
- await authorizeDeviceCode(req, res);
513
- }
514
- /**
515
- * Build and write the standard /token success body for ``sub``.
516
- *
517
- * Issues a fresh access token AND a fresh refresh token. The refresh token
518
- * lets long-running MCP clients renew the 1h access token without re-running
519
- * the upstream OAuth flow (issue #261). ``scope`` advertises ``offline_access``
520
- * so clients know a refresh token was granted. The local refresh token is
521
- * keyed only on ``sub`` -- it does NOT carry upstream provider tokens;
522
- * consumers that need upstream refresh manage that via ``onTokenReceived``.
523
- */
524
- async function issueTokenResponse(res, sub) {
525
- const accessToken = await jwtIssuer.issueAccessToken(sub);
526
- const refreshToken = await jwtIssuer.issueRefreshToken(sub);
527
- jsonResponse(res, 200, {
528
- access_token: accessToken,
529
- token_type: 'Bearer',
530
- expires_in: 3600,
531
- refresh_token: refreshToken,
532
- scope: 'offline_access'
533
- });
534
- }
535
- /**
536
- * Handle ``grant_type=refresh_token`` (RFC 6749 §6) statelessly. Verifies the
537
- * presented refresh token's signature / iss / aud / exp / ``typ`` via the JWT
538
- * issuer (no server-side store), extracts ``sub``, and issues a NEW access
539
- * token AND a NEW refresh token (rotation). The refresh token is
540
- * self-contained, so rotation here is stateless: the old refresh token simply
541
- * expires on its own 30-day clock.
542
- */
543
- async function handleRefreshToken(res, form) {
544
- const refreshToken = form.refresh_token;
545
- if (!refreshToken) {
546
- jsonResponse(res, 400, {
547
- error: 'invalid_request',
548
- error_description: 'Missing refresh_token'
549
- });
550
- return;
551
- }
552
- let sub;
553
- try {
554
- const claims = await jwtIssuer.verifyRefreshToken(refreshToken);
555
- sub = claims.sub;
556
- }
557
- catch {
558
- jsonResponse(res, 400, { error: 'invalid_grant' });
559
- return;
560
- }
561
- await issueTokenResponse(res, sub);
562
- }
563
- async function token(req, res) {
564
- let form;
565
- try {
566
- form = await parseFormBody(req);
567
- }
568
- catch {
569
- jsonResponse(res, 400, { error: 'invalid_request' });
570
- return;
571
- }
572
- if (form.grant_type === 'refresh_token') {
573
- await handleRefreshToken(res, form);
574
- return;
575
- }
576
- if (form.grant_type !== 'authorization_code') {
577
- jsonResponse(res, 400, { error: 'unsupported_grant_type' });
578
- return;
579
- }
580
- const code = form.code;
581
- const codeVerifier = form.code_verifier;
582
- if (!code || !codeVerifier) {
583
- jsonResponse(res, 400, {
584
- error: 'invalid_request',
585
- error_description: 'Missing code or code_verifier'
586
- });
587
- return;
588
- }
589
- const entry = await authCodes.get(code);
590
- if (entry === undefined) {
591
- jsonResponse(res, 400, { error: 'invalid_grant' });
592
- return;
593
- }
594
- await authCodes.delete(code);
595
- if (entry.codeChallengeMethod !== 'S256') {
596
- jsonResponse(res, 400, {
597
- error: 'invalid_request',
598
- error_description: 'Only S256 is supported'
599
- });
600
- return;
601
- }
602
- if (!s256Verify(codeVerifier, entry.codeChallenge)) {
603
- jsonResponse(res, 400, { error: 'invalid_grant' });
604
- return;
605
- }
606
- await issueTokenResponse(res, entry.sub);
607
- }
608
- async function setupStatusHandler(_req, res) {
609
- jsonResponse(res, 200, setupStatus);
610
- }
611
- async function wellKnownAs(req, res) {
612
- jsonResponse(res, 200, authorizationServerMetadata(getBaseUrl(req)));
613
- }
614
- /**
615
- * RFC 7591 Dynamic Client Registration.
616
- *
617
- * Since this server accepts a fixed public client id (`local-browser`)
618
- * with no per-client credentials, DCR echoes back whatever
619
- * ``redirect_uris`` / grant types the client submitted and returns
620
- * the fixed id. This satisfies MCP clients that refuse to speak OAuth
621
- * without DCR (e.g. Python SDK ``OAuthClientProvider``).
622
- */
623
- async function registerHandler(req, res) {
624
- let body = {};
625
- try {
626
- const raw = await parseJsonBody(req);
627
- body = raw;
628
- }
629
- catch {
630
- // fall through with empty body; clients can still get defaults
631
- }
632
- const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris : [];
633
- const grantTypes = Array.isArray(body.grant_types) ? body.grant_types : ['authorization_code'];
634
- const responseTypes = Array.isArray(body.response_types) ? body.response_types : ['code'];
635
- const clientName = typeof body.client_name === 'string' ? body.client_name : 'mcp-client';
636
- jsonResponse(res, 201, {
637
- client_id: 'local-browser',
638
- client_name: clientName,
639
- redirect_uris: redirectUris,
640
- grant_types: grantTypes,
641
- response_types: responseTypes,
642
- token_endpoint_auth_method: 'none'
643
- });
644
- }
645
- async function wellKnownPr(req, res) {
646
- const base = getBaseUrl(req);
647
- jsonResponse(res, 200, protectedResourceMetadata(base, [base]));
648
- }
649
- async function wellKnownJwks(_req, res) {
650
- jsonResponse(res, 200, await jwtIssuer.getJwks());
651
- }
652
- /**
653
- * GET / -- auto-generate PKCE and redirect to /authorize.
654
- *
655
- * Parity with createLocalOAuthApp's rootHandler. Users arriving from a
656
- * bookmark / log line at the bare server URL get a usable OAuth flow
657
- * without having to construct PKCE params manually. The delegated
658
- * /authorize endpoint validates these params against its delegated
659
- * upstream configuration, so using "local-browser" as client_id works
660
- * for both redirect and device_code flows.
661
- */
662
- async function rootHandler(req, res) {
663
- const base = getBaseUrl(req);
664
- const codeVerifier = randomBytes(64).toString('base64url');
665
- const codeChallenge = createHash('sha256').update(codeVerifier, 'ascii').digest('base64url');
666
- const state = randomBytes(16).toString('base64url');
667
- const params = new URLSearchParams({
668
- client_id: 'local-browser',
669
- redirect_uri: `${base}/callback-done`,
670
- state,
671
- code_challenge: codeChallenge,
672
- code_challenge_method: 'S256'
673
- });
674
- res.writeHead(302, { Location: `/authorize?${params.toString()}` });
675
- res.end();
676
- }
677
- /**
678
- * GET /callback-done -- terminal "tab can be closed" landing page.
679
- * Mirrors createLocalOAuthApp so the redirect target from rootHandler
680
- * resolves to a friendly message instead of 404 when the user finishes
681
- * the delegated OAuth flow.
682
- */
683
- async function callbackDoneHandler(_req, res) {
684
- const html = "<!DOCTYPE html><html><head><meta charset='utf-8'>" +
685
- '<title>Setup complete</title>' +
686
- '<style>body{font-family:-apple-system,Segoe UI,sans-serif;' +
687
- 'background:#111;color:#eee;display:flex;align-items:center;' +
688
- 'justify-content:center;height:100vh;margin:0}' +
689
- '.box{text-align:center;padding:2rem;border:1px solid #333;' +
690
- 'border-radius:8px;background:#1a1a1a}' +
691
- 'h1{color:#34c759;margin:0 0 0.5rem}p{color:#aaa;margin:0}' +
692
- '</style></head><body><div class="box">' +
693
- '<h1>Setup complete</h1>' +
694
- '<p>You can close this tab.</p>' +
695
- '</div></body></html>';
696
- htmlResponse(res, 200, html);
697
- }
698
- // ------------------------------------------------------------------
699
- // Relay password gate adapters.
700
- //
701
- // Mirrors createLocalOAuthApp's adapter block. The relay-login module
702
- // exposes Express-style handlers (``req.body``, ``res.status().send()``);
703
- // here we adapt them onto the project's ``IncomingMessage`` /
704
- // ``ServerResponse`` router. Only ``/authorize`` GET goes through
705
- // ``relayMw``; ``/token``, ``/register``, ``/setup-status``, ``/callback``,
706
- // ``/.well-known/*``, ``/`` and ``/callback-done`` are intentionally NOT
707
- // gated — they're machine endpoints (DCR / token exchange / metadata),
708
- // mid-OAuth callbacks (``/callback`` is hit by the upstream provider, not
709
- // a logged-in user), or auto-bootstrap helpers.
710
- // ------------------------------------------------------------------
711
- function parseCookies(req) {
712
- const header = req.headers.cookie;
713
- if (!header)
714
- return {};
715
- const out = {};
716
- // Use a single-pass loop instead of `split(';')` to avoid array
717
- // allocations and intermediate string objects in this hot path.
718
- let pos = 0;
719
- while (pos < header.length) {
720
- let nextSemi = header.indexOf(';', pos);
721
- if (nextSemi < 0)
722
- nextSemi = header.length;
723
- const eq = header.indexOf('=', pos);
724
- if (eq >= 0 && eq < nextSemi) {
725
- const k = header.substring(pos, eq).trim();
726
- const v = header.substring(eq + 1, nextSemi).trim();
727
- if (k.length > 0)
728
- out[k] = decodeURIComponent(v);
729
- }
730
- pos = nextSemi + 1;
731
- }
732
- return out;
733
- }
734
- function clientIp(req) {
735
- const fwd = req.headers['x-forwarded-for'];
736
- if (typeof fwd === 'string' && fwd.length > 0) {
737
- const idx = fwd.indexOf(',');
738
- return (idx !== -1 ? fwd.substring(0, idx) : fwd).trim();
739
- }
740
- return req.socket.remoteAddress ?? 'unknown';
741
- }
742
- function buildResAdapter(res) {
743
- let statusCode = 200;
744
- const headers = {};
745
- let completed = false;
746
- const setHeader = (name, value) => {
747
- headers[name] = value;
748
- };
749
- const adapter = {
750
- status: (code) => {
751
- statusCode = Number(code);
752
- return adapter;
753
- },
754
- header: (name, value) => {
755
- setHeader(String(name), String(value));
756
- return adapter;
757
- },
758
- set: (name, value) => {
759
- setHeader(String(name), String(value));
760
- return adapter;
761
- },
762
- cookie: (name, value, options) => {
763
- const opts = (options ?? {});
764
- const parts = [`${String(name)}=${String(value)}`];
765
- if (opts.maxAge !== undefined) {
766
- parts.push(`Max-Age=${Math.floor(Number(opts.maxAge) / 1000)}`);
767
- }
768
- if (opts.httpOnly === true)
769
- parts.push('HttpOnly');
770
- if (opts.secure === true)
771
- parts.push('Secure');
772
- if (typeof opts.sameSite === 'string') {
773
- const v = opts.sameSite;
774
- parts.push(`SameSite=${v.charAt(0).toUpperCase() + v.slice(1)}`);
775
- }
776
- parts.push('Path=/');
777
- const existing = headers['Set-Cookie'];
778
- const next = parts.join('; ');
779
- headers['Set-Cookie'] = existing !== undefined ? `${existing}, ${next}` : next;
780
- return adapter;
781
- },
782
- send: (body) => {
783
- if (completed)
784
- return adapter;
785
- completed = true;
786
- if (!('Content-Type' in headers)) {
787
- headers['Content-Type'] = 'text/html; charset=utf-8';
788
- }
789
- // Anti-clickjacking for HTML responses
790
- const ctype = headers['Content-Type'];
791
- if (ctype && ctype.toLowerCase().includes('text/html')) {
792
- headers['X-Frame-Options'] = 'DENY';
793
- }
794
- res.writeHead(statusCode, headers);
795
- res.end(body === undefined ? '' : String(body));
796
- return adapter;
797
- },
798
- redirect: (url) => {
799
- if (completed)
800
- return adapter;
801
- completed = true;
802
- headers.Location = String(url);
803
- res.writeHead(302, headers);
804
- res.end();
805
- return adapter;
806
- }
807
- };
808
- return { adapter, isComplete: () => completed };
809
- }
810
- /**
811
- * Wrap a router handler with the relay-login middleware. If the cookie
812
- * gate redirects, the inner handler is never called; otherwise the inner
813
- * handler runs against the original ``req`` / ``res``.
814
- */
815
- function withRelayGate(inner) {
816
- return async (req, res) => {
817
- if (!relayPassword) {
818
- await inner(req, res);
819
- return;
820
- }
821
- const cookies = parseCookies(req);
822
- const expressReq = {
823
- cookies,
824
- originalUrl: req.url ?? '/'
825
- };
826
- const { adapter, isComplete } = buildResAdapter(res);
827
- let proceed = false;
828
- await relayMw(expressReq, adapter, () => {
829
- proceed = true;
830
- });
831
- if (proceed && !isComplete()) {
832
- await inner(req, res);
833
- }
834
- };
835
- }
836
- async function loginGetRoute(req, res) {
837
- const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
838
- const expressReq = { query: { next: url.searchParams.get('next') ?? '/authorize' } };
839
- const { adapter } = buildResAdapter(res);
840
- await loginGetHandler(expressReq, adapter);
841
- }
842
- async function loginPostRoute(req, res) {
843
- let body = {};
844
- try {
845
- body = await parseFormBody(req);
846
- }
847
- catch {
848
- res.writeHead(400, { 'Content-Type': 'application/json' });
849
- res.end(JSON.stringify({ error: 'invalid_request' }));
850
- return;
851
- }
852
- const expressReq = { body, ip: clientIp(req) };
853
- const { adapter } = buildResAdapter(res);
854
- await loginPostHandler(expressReq, adapter);
855
- }
856
- const routes = [
857
- { method: 'GET', path: '/', handler: rootHandler },
858
- { method: 'GET', path: '/callback-done', handler: callbackDoneHandler },
859
- { method: 'GET', path: '/login', handler: loginGetRoute },
860
- { method: 'POST', path: '/login', handler: loginPostRoute },
861
- { method: 'GET', path: '/authorize', handler: withRelayGate(authorize) },
862
- { method: 'POST', path: '/token', handler: token },
863
- { method: 'POST', path: '/register', handler: registerHandler },
864
- { method: 'GET', path: '/setup-status', handler: setupStatusHandler },
865
- { method: 'GET', path: '/.well-known/oauth-authorization-server', handler: wellKnownAs },
866
- { method: 'GET', path: '/.well-known/oauth-protected-resource', handler: wellKnownPr },
867
- { method: 'GET', path: '/.well-known/jwks.json', handler: wellKnownJwks }
868
- ];
869
- if (options.flow === 'redirect') {
870
- routes.push({ method: 'GET', path: callbackPath, handler: callback });
871
- }
872
- const handler = createRouter(routes);
873
- async function shutdown() {
874
- for (const controller of pollControllers) {
875
- controller.abort();
876
- }
877
- pollControllers.clear();
878
- }
879
- return { handler, jwtIssuer, markSetupComplete, shutdown };
880
- }
881
- //# sourceMappingURL=delegated-oauth-app.js.map