@n24q02m/hull 0.0.0-stage → 0.4.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +166 -2
- package/build/auth/credential-form.d.ts +163 -0
- package/build/auth/credential-form.d.ts.map +1 -0
- package/build/auth/credential-form.js +1869 -0
- package/build/auth/credential-form.js.map +1 -0
- package/build/auth/delegated-oauth-app.d.ts +90 -0
- package/build/auth/delegated-oauth-app.d.ts.map +1 -0
- package/build/auth/delegated-oauth-app.js +881 -0
- package/build/auth/delegated-oauth-app.js.map +1 -0
- package/build/auth/index.d.ts +12 -0
- package/build/auth/index.d.ts.map +1 -0
- package/build/auth/index.js +12 -0
- package/build/auth/index.js.map +1 -0
- package/build/auth/local-oauth-app.d.ts +152 -0
- package/build/auth/local-oauth-app.d.ts.map +1 -0
- package/build/auth/local-oauth-app.js +975 -0
- package/build/auth/local-oauth-app.js.map +1 -0
- package/build/auth/relay-login.d.ts +46 -0
- package/build/auth/relay-login.d.ts.map +1 -0
- package/build/auth/relay-login.js +197 -0
- package/build/auth/relay-login.js.map +1 -0
- package/build/auth/router.d.ts +18 -0
- package/build/auth/router.d.ts.map +1 -0
- package/build/auth/router.js +62 -0
- package/build/auth/router.js.map +1 -0
- package/build/auth/session-store.d.ts +49 -0
- package/build/auth/session-store.d.ts.map +1 -0
- package/build/auth/session-store.js +94 -0
- package/build/auth/session-store.js.map +1 -0
- package/build/auth/stable-sub.d.ts +27 -0
- package/build/auth/stable-sub.d.ts.map +1 -0
- package/build/auth/stable-sub.js +38 -0
- package/build/auth/stable-sub.js.map +1 -0
- package/build/auth/well-known.d.ts +4 -0
- package/build/auth/well-known.d.ts.map +1 -0
- package/build/auth/well-known.js +26 -0
- package/build/auth/well-known.js.map +1 -0
- package/build/cli/build-cli.d.ts +97 -0
- package/build/cli/build-cli.d.ts.map +1 -0
- package/build/cli/build-cli.js +309 -0
- package/build/cli/build-cli.js.map +1 -0
- package/build/cli/index.d.ts +2 -0
- package/build/cli/index.d.ts.map +1 -0
- package/build/cli/index.js +2 -0
- package/build/cli/index.js.map +1 -0
- package/build/crypto/aes.d.ts +8 -0
- package/build/crypto/aes.d.ts.map +1 -0
- package/build/crypto/aes.js +27 -0
- package/build/crypto/aes.js.map +1 -0
- package/build/crypto/ecdh.d.ts +5 -0
- package/build/crypto/ecdh.d.ts.map +1 -0
- package/build/crypto/ecdh.js +16 -0
- package/build/crypto/ecdh.js.map +1 -0
- package/build/crypto/index.d.ts +4 -0
- package/build/crypto/index.d.ts.map +1 -0
- package/build/crypto/index.js +4 -0
- package/build/crypto/index.js.map +1 -0
- package/build/crypto/kdf.d.ts +15 -0
- package/build/crypto/kdf.d.ts.map +1 -0
- package/build/crypto/kdf.js +26 -0
- package/build/crypto/kdf.js.map +1 -0
- package/build/index.d.ts +19 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +28 -0
- package/build/index.js.map +1 -0
- package/build/lifecycle/lock.d.ts +68 -0
- package/build/lifecycle/lock.d.ts.map +1 -0
- package/build/lifecycle/lock.js +173 -0
- package/build/lifecycle/lock.js.map +1 -0
- package/build/oauth/cache.d.ts +22 -0
- package/build/oauth/cache.d.ts.map +1 -0
- package/build/oauth/cache.js +24 -0
- package/build/oauth/cache.js.map +1 -0
- package/build/oauth/index.d.ts +6 -0
- package/build/oauth/index.d.ts.map +1 -0
- package/build/oauth/index.js +6 -0
- package/build/oauth/index.js.map +1 -0
- package/build/oauth/jwt-issuer.d.ts +87 -0
- package/build/oauth/jwt-issuer.d.ts.map +1 -0
- package/build/oauth/jwt-issuer.js +205 -0
- package/build/oauth/jwt-issuer.js.map +1 -0
- package/build/oauth/pkce.d.ts +10 -0
- package/build/oauth/pkce.d.ts.map +1 -0
- package/build/oauth/pkce.js +34 -0
- package/build/oauth/pkce.js.map +1 -0
- package/build/oauth/provider.d.ts +52 -0
- package/build/oauth/provider.d.ts.map +1 -0
- package/build/oauth/provider.js +98 -0
- package/build/oauth/provider.js.map +1 -0
- package/build/oauth/user-store.d.ts +25 -0
- package/build/oauth/user-store.d.ts.map +1 -0
- package/build/oauth/user-store.js +85 -0
- package/build/oauth/user-store.js.map +1 -0
- package/build/relay/browser.d.ts +15 -0
- package/build/relay/browser.d.ts.map +1 -0
- package/build/relay/browser.js +121 -0
- package/build/relay/browser.js.map +1 -0
- package/build/relay/client.d.ts +36 -0
- package/build/relay/client.d.ts.map +1 -0
- package/build/relay/client.js +135 -0
- package/build/relay/client.js.map +1 -0
- package/build/relay/session.d.ts +12 -0
- package/build/relay/session.d.ts.map +1 -0
- package/build/relay/session.js +55 -0
- package/build/relay/session.js.map +1 -0
- package/build/relay/tool-helpers.d.ts +85 -0
- package/build/relay/tool-helpers.d.ts.map +1 -0
- package/build/relay/tool-helpers.js +103 -0
- package/build/relay/tool-helpers.js.map +1 -0
- package/build/relay/wordlist.d.ts +2 -0
- package/build/relay/wordlist.d.ts.map +1 -0
- package/build/relay/wordlist.js +7782 -0
- package/build/relay/wordlist.js.map +1 -0
- package/build/schema/types.d.ts +67 -0
- package/build/schema/types.d.ts.map +1 -0
- package/build/schema/types.js +2 -0
- package/build/schema/types.js.map +1 -0
- package/build/storage/atomic-write.d.ts +8 -0
- package/build/storage/atomic-write.d.ts.map +1 -0
- package/build/storage/atomic-write.js +26 -0
- package/build/storage/atomic-write.js.map +1 -0
- package/build/storage/backends.d.ts +74 -0
- package/build/storage/backends.d.ts.map +1 -0
- package/build/storage/backends.js +193 -0
- package/build/storage/backends.js.map +1 -0
- package/build/storage/config-file.d.ts +35 -0
- package/build/storage/config-file.d.ts.map +1 -0
- package/build/storage/config-file.js +259 -0
- package/build/storage/config-file.js.map +1 -0
- package/build/storage/credential-store.d.ts +71 -0
- package/build/storage/credential-store.d.ts.map +1 -0
- package/build/storage/credential-store.js +91 -0
- package/build/storage/credential-store.js.map +1 -0
- package/build/storage/data-dir.d.ts +58 -0
- package/build/storage/data-dir.d.ts.map +1 -0
- package/build/storage/data-dir.js +79 -0
- package/build/storage/data-dir.js.map +1 -0
- package/build/storage/encryption.d.ts +9 -0
- package/build/storage/encryption.d.ts.map +1 -0
- package/build/storage/encryption.js +27 -0
- package/build/storage/encryption.js.map +1 -0
- package/build/storage/home-dir.d.ts +11 -0
- package/build/storage/home-dir.d.ts.map +1 -0
- package/build/storage/home-dir.js +18 -0
- package/build/storage/home-dir.js.map +1 -0
- package/build/storage/index.d.ts +12 -0
- package/build/storage/index.d.ts.map +1 -0
- package/build/storage/index.js +15 -0
- package/build/storage/index.js.map +1 -0
- package/build/storage/machine-id.d.ts +4 -0
- package/build/storage/machine-id.d.ts.map +1 -0
- package/build/storage/machine-id.js +73 -0
- package/build/storage/machine-id.js.map +1 -0
- package/build/storage/mode.d.ts +23 -0
- package/build/storage/mode.d.ts.map +1 -0
- package/build/storage/mode.js +46 -0
- package/build/storage/mode.js.map +1 -0
- package/build/storage/per-plugin-store.d.ts +65 -0
- package/build/storage/per-plugin-store.d.ts.map +1 -0
- package/build/storage/per-plugin-store.js +194 -0
- package/build/storage/per-plugin-store.js.map +1 -0
- package/build/storage/resolver.d.ts +7 -0
- package/build/storage/resolver.d.ts.map +1 -0
- package/build/storage/resolver.js +38 -0
- package/build/storage/resolver.js.map +1 -0
- package/build/storage/session-lock.d.ts +19 -0
- package/build/storage/session-lock.d.ts.map +1 -0
- package/build/storage/session-lock.js +105 -0
- package/build/storage/session-lock.js.map +1 -0
- package/build/token-auth/authenticator.d.ts +70 -0
- package/build/token-auth/authenticator.d.ts.map +1 -0
- package/build/token-auth/authenticator.js +146 -0
- package/build/token-auth/authenticator.js.map +1 -0
- package/build/token-auth/cli.d.ts +38 -0
- package/build/token-auth/cli.d.ts.map +1 -0
- package/build/token-auth/cli.js +192 -0
- package/build/token-auth/cli.js.map +1 -0
- package/build/token-auth/context.d.ts +30 -0
- package/build/token-auth/context.d.ts.map +1 -0
- package/build/token-auth/context.js +32 -0
- package/build/token-auth/context.js.map +1 -0
- package/build/token-auth/http-guard.d.ts +26 -0
- package/build/token-auth/http-guard.d.ts.map +1 -0
- package/build/token-auth/http-guard.js +51 -0
- package/build/token-auth/http-guard.js.map +1 -0
- package/build/token-auth/index.d.ts +14 -0
- package/build/token-auth/index.d.ts.map +1 -0
- package/build/token-auth/index.js +14 -0
- package/build/token-auth/index.js.map +1 -0
- package/build/token-auth/limiter.d.ts +20 -0
- package/build/token-auth/limiter.d.ts.map +1 -0
- package/build/token-auth/limiter.js +41 -0
- package/build/token-auth/limiter.js.map +1 -0
- package/build/token-auth/loopback.d.ts +17 -0
- package/build/token-auth/loopback.d.ts.map +1 -0
- package/build/token-auth/loopback.js +62 -0
- package/build/token-auth/loopback.js.map +1 -0
- package/build/token-auth/py-compat.d.ts +24 -0
- package/build/token-auth/py-compat.d.ts.map +1 -0
- package/build/token-auth/py-compat.js +76 -0
- package/build/token-auth/py-compat.js.map +1 -0
- package/build/token-auth/tokens.d.ts +36 -0
- package/build/token-auth/tokens.d.ts.map +1 -0
- package/build/token-auth/tokens.js +115 -0
- package/build/token-auth/tokens.js.map +1 -0
- package/build/token-auth/users.d.ts +62 -0
- package/build/token-auth/users.d.ts.map +1 -0
- package/build/token-auth/users.js +174 -0
- package/build/token-auth/users.js.map +1 -0
- package/build/transport/cache.d.ts +19 -0
- package/build/transport/cache.d.ts.map +1 -0
- package/build/transport/cache.js +76 -0
- package/build/transport/cache.js.map +1 -0
- package/build/transport/index.d.ts +5 -0
- package/build/transport/index.d.ts.map +1 -0
- package/build/transport/index.js +4 -0
- package/build/transport/index.js.map +1 -0
- package/build/transport/local-server.d.ts +173 -0
- package/build/transport/local-server.d.ts.map +1 -0
- package/build/transport/local-server.js +457 -0
- package/build/transport/local-server.js.map +1 -0
- package/build/transport/oauth-middleware.d.ts +45 -0
- package/build/transport/oauth-middleware.d.ts.map +1 -0
- package/build/transport/oauth-middleware.js +86 -0
- package/build/transport/oauth-middleware.js.map +1 -0
- package/build/transport/streamable-http.d.ts +54 -0
- package/build/transport/streamable-http.d.ts.map +1 -0
- package/build/transport/streamable-http.js +57 -0
- package/build/transport/streamable-http.js.map +1 -0
- package/package.json +107 -4
|
@@ -0,0 +1,975 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local OAuth 2.1 Authorization Server as an HTTP request handler.
|
|
3
|
+
*
|
|
4
|
+
* Provides a self-hosted Authorization Server for single-user MCP servers.
|
|
5
|
+
* Implements the OAuth 2.1 PKCE flow with credential collection via a
|
|
6
|
+
* browser-rendered form.
|
|
7
|
+
*
|
|
8
|
+
* Routes:
|
|
9
|
+
* - GET / -- Auto-bootstrap PKCE then redirect to /authorize
|
|
10
|
+
* - GET /authorize -- Render credential form
|
|
11
|
+
* - POST /authorize -- Save credentials, return auth code
|
|
12
|
+
* - POST /otp -- Submit multi-step credential (OTP / 2FA password)
|
|
13
|
+
* - POST /token -- Exchange auth code + PKCE verifier for JWT
|
|
14
|
+
* - GET /setup-status -- Poll background setup completion
|
|
15
|
+
* - GET /callback-done -- Friendly "tab can be closed" page after PKCE callback
|
|
16
|
+
* - GET /.well-known/oauth-authorization-server -- RFC 8414 metadata
|
|
17
|
+
* - GET /.well-known/oauth-protected-resource -- RFC 9728 metadata
|
|
18
|
+
*
|
|
19
|
+
* The /mcp endpoint is NOT included -- it is mounted by the transport layer.
|
|
20
|
+
*
|
|
21
|
+
* This is a TypeScript port of core-py's ``local_oauth_app.py``. Behavior,
|
|
22
|
+
* protocol, and TTL constants are kept identical for cross-language parity.
|
|
23
|
+
*/
|
|
24
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
25
|
+
import { JWTIssuer } from '../oauth/jwt-issuer.js';
|
|
26
|
+
import { markSetupComplete as markConfigSetupComplete } from '../storage/credential-store.js';
|
|
27
|
+
import { isOAuthField, isSecretField, renderCredentialForm } from './credential-form.js';
|
|
28
|
+
import { configureRelayLogin, createRelayLoginMiddleware, loginGetHandler, loginPostHandler } from './relay-login.js';
|
|
29
|
+
import { createRouter, htmlResponse, jsonResponse, parseFormBody, parseJsonBody } from './router.js';
|
|
30
|
+
import { deriveStableSub } from './stable-sub.js';
|
|
31
|
+
import { authorizationServerMetadata, protectedResourceMetadata } from './well-known.js';
|
|
32
|
+
// Auth codes and PKCE sessions expire after 10 minutes.
|
|
33
|
+
const AUTH_CODE_TTL_S = 600;
|
|
34
|
+
const SESSION_TTL_S = 600;
|
|
35
|
+
// Multi-step auth (OTP / 2FA password) constraints.
|
|
36
|
+
const OTP_TIMEOUT_S = 300;
|
|
37
|
+
const OTP_MAX_ATTEMPTS = 5;
|
|
38
|
+
/**
|
|
39
|
+
* Verify PKCE S256: ``base64url(sha256(code_verifier)) == code_challenge``,
|
|
40
|
+
* using a timing-safe comparison to prevent timing attacks.
|
|
41
|
+
*/
|
|
42
|
+
function s256Verify(codeVerifier, codeChallenge) {
|
|
43
|
+
const computed = createHash('sha256').update(codeVerifier, 'ascii').digest('base64url');
|
|
44
|
+
const computedBuf = Buffer.from(computed, 'ascii');
|
|
45
|
+
const challengeBuf = Buffer.from(codeChallenge, 'ascii');
|
|
46
|
+
const isLengthEqual = computedBuf.length === challengeBuf.length;
|
|
47
|
+
const compareBuf = isLengthEqual ? challengeBuf : computedBuf;
|
|
48
|
+
try {
|
|
49
|
+
return timingSafeEqual(computedBuf, compareBuf) && isLengthEqual;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Prune entries older than ``ttlMs`` milliseconds from an in-memory store. */
|
|
56
|
+
function pruneExpired(store, ttlMs) {
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
for (const [key, value] of store) {
|
|
59
|
+
if (now - value.createdAt > ttlMs)
|
|
60
|
+
store.delete(key);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Derive the public base URL of this request (protocol + host, no trailing slash).
|
|
65
|
+
*
|
|
66
|
+
* Resolution order:
|
|
67
|
+
* 1. ``PUBLIC_URL`` env var -- trusted, explicit. This is the remote-deploy
|
|
68
|
+
* convention (oci-vm-prod) where the container sits behind CF Tunnel ->
|
|
69
|
+
* Caddy (HTTP internal) but is served to clients over HTTPS. Without this,
|
|
70
|
+
* OAuth 2.1 metadata would leak ``http://`` as the issuer and strict
|
|
71
|
+
* clients reject the discovery document.
|
|
72
|
+
* 2. ``X-Forwarded-Proto`` header (first value) + Host header -- for reverse
|
|
73
|
+
* proxies that forward the original scheme.
|
|
74
|
+
* 3. Socket ``encrypted`` flag -- TLS-terminated at this process.
|
|
75
|
+
* 4. ``http://<host>`` fallback -- plain local dev.
|
|
76
|
+
*/
|
|
77
|
+
function getBaseUrl(req) {
|
|
78
|
+
const publicUrl = process.env.PUBLIC_URL;
|
|
79
|
+
if (publicUrl !== undefined && publicUrl.length > 0) {
|
|
80
|
+
return publicUrl.replace(/\/+$/, '');
|
|
81
|
+
}
|
|
82
|
+
const host = req.headers.host ?? 'localhost';
|
|
83
|
+
const encrypted = req.socket.encrypted === true;
|
|
84
|
+
const forwardedProto = req.headers['x-forwarded-proto'];
|
|
85
|
+
let protocol = encrypted ? 'https' : 'http';
|
|
86
|
+
if (typeof forwardedProto === 'string' && forwardedProto.length > 0) {
|
|
87
|
+
const commaIdx = forwardedProto.indexOf(',');
|
|
88
|
+
protocol = (commaIdx !== -1 ? forwardedProto.substring(0, commaIdx) : forwardedProto).trim();
|
|
89
|
+
}
|
|
90
|
+
return `${protocol}://${host}`;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Create OAuth 2.1 Authorization Server HTTP handler.
|
|
94
|
+
*
|
|
95
|
+
* Returns a handler compatible with ``http.createServer`` along with the
|
|
96
|
+
* ``JWTIssuer`` (for the transport layer to verify Bearer tokens) and a
|
|
97
|
+
* ``markSetupComplete`` function for background setup callbacks (e.g. GDrive
|
|
98
|
+
* device code flow).
|
|
99
|
+
*/
|
|
100
|
+
export async function createLocalOAuthApp(options) {
|
|
101
|
+
const signingSecret = process.env.MCP_JWT_SIGNING_SECRET ?? process.env.CREDENTIAL_SECRET ?? null;
|
|
102
|
+
const jwtIssuer = options.jwtIssuer ?? new JWTIssuer(options.serverName, undefined, signingSecret);
|
|
103
|
+
await jwtIssuer.init();
|
|
104
|
+
// In-memory stores keyed by nonce / auth_code. Each entry has a ``createdAt``
|
|
105
|
+
// for TTL expiry.
|
|
106
|
+
const pendingSessions = new Map();
|
|
107
|
+
const authCodes = new Map();
|
|
108
|
+
// Server-side prefill keyed by OAuth ``state`` (the PKCE state token chosen
|
|
109
|
+
// by the client BEFORE the GET /authorize redirect). The E2E driver POSTs
|
|
110
|
+
// skret-derived form values here so the URL it announces to the user does
|
|
111
|
+
// NOT contain credential bytes. Without this, ``?prefill_TELEGRAM_PHONE=
|
|
112
|
+
// %2B84...`` would land in browser history, server access logs, screenshots
|
|
113
|
+
// and HTTP referrer headers — every place a URL leaks.
|
|
114
|
+
const pendingPrefills = new Map();
|
|
115
|
+
const PREFILL_TTL_S = 300;
|
|
116
|
+
// Single-user local mode: one pending multi-step session at a time.
|
|
117
|
+
let pendingStep = null;
|
|
118
|
+
const setupStatus = { gdrive: 'idle' };
|
|
119
|
+
// Edge auth password gate. When ``MCP_RELAY_PASSWORD`` is set, /authorize
|
|
120
|
+
// GET + POST + /authorize/prefill are fronted by a cookie-session
|
|
121
|
+
// middleware. Empty password disables the gate (single-user dev). See
|
|
122
|
+
// spec ``2026-05-01-stdio-pure-http-multiuser §4.2.1``. Configured here
|
|
123
|
+
// (not module-scope) so multi-app test setups don't leak state across
|
|
124
|
+
// apps; the relay-login module itself keeps a single shared password
|
|
125
|
+
// string, intentional because in deploy each container hosts one app.
|
|
126
|
+
const relayPassword = process.env.MCP_RELAY_PASSWORD ?? '';
|
|
127
|
+
configureRelayLogin(relayPassword);
|
|
128
|
+
const relayMw = createRelayLoginMiddleware({ password: relayPassword });
|
|
129
|
+
function markPendingStep(sub) {
|
|
130
|
+
pendingStep = { active: true, createdAt: Date.now(), attempts: 0, sub };
|
|
131
|
+
}
|
|
132
|
+
function clearPendingStep() {
|
|
133
|
+
pendingStep = null;
|
|
134
|
+
}
|
|
135
|
+
// ------------------------------------------------------------------
|
|
136
|
+
// Route handlers
|
|
137
|
+
// ------------------------------------------------------------------
|
|
138
|
+
async function authorizeGet(req, res) {
|
|
139
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
140
|
+
const params = url.searchParams;
|
|
141
|
+
const clientId = params.get('client_id');
|
|
142
|
+
const redirectUri = params.get('redirect_uri');
|
|
143
|
+
const state = params.get('state');
|
|
144
|
+
const codeChallenge = params.get('code_challenge');
|
|
145
|
+
const codeChallengeMethod = params.get('code_challenge_method') ?? 'S256';
|
|
146
|
+
if (!clientId || !redirectUri || !state || !codeChallenge) {
|
|
147
|
+
jsonResponse(res, 400, {
|
|
148
|
+
error: 'invalid_request',
|
|
149
|
+
error_description: 'Missing required parameters'
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
// Resolve prefill values for the form. Two channels, in priority order:
|
|
154
|
+
// 1. Server-side store keyed by ``state`` — written by the E2E driver
|
|
155
|
+
// via POST /authorize/prefill BEFORE the user opens the URL. This is
|
|
156
|
+
// the safe channel; nothing leaves the server boundary.
|
|
157
|
+
// 2. URL query string ``?prefill_<KEY>=<VALUE>`` — legacy fallback for
|
|
158
|
+
// callers that have not migrated yet. Deprecated; emitted to
|
|
159
|
+
// ``X-Prefill-Source: url`` so callers can find them via access logs.
|
|
160
|
+
// Renderers receive the flat ``{KEY: VALUE}`` map and emit ``value="..."``
|
|
161
|
+
// on matching inputs.
|
|
162
|
+
const prefill = {};
|
|
163
|
+
pruneExpired(pendingPrefills, PREFILL_TTL_S * 1000);
|
|
164
|
+
const stored = pendingPrefills.get(state);
|
|
165
|
+
if (stored) {
|
|
166
|
+
// Explicit per-key copy (not Object.assign / spread) — values landed
|
|
167
|
+
// here via authorizePrefill which coerces every value through String()
|
|
168
|
+
// and rejects empty strings, but we restate the contract here so static
|
|
169
|
+
// analyzers don't flag the merge as a mass-assignment sink.
|
|
170
|
+
// nosemgrep: javascript.express.security.express-data-exfiltration.express-data-exfiltration
|
|
171
|
+
for (const [k, v] of Object.entries(stored.data)) {
|
|
172
|
+
prefill[k] = String(v);
|
|
173
|
+
}
|
|
174
|
+
pendingPrefills.delete(state);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
params.forEach((value, key) => {
|
|
178
|
+
if (key.startsWith('prefill_')) {
|
|
179
|
+
prefill[key.slice('prefill_'.length)] = value;
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
const nonce = randomBytes(32).toString('base64url');
|
|
184
|
+
// Generate a per-authorize-request subject here (not at /token time) so the
|
|
185
|
+
// credential save callback and the eventual JWT share the same ``sub``. If
|
|
186
|
+
// this were derived at /token, concurrent authorize requests would collide
|
|
187
|
+
// on a static 'local-user' subject and leak credentials across users.
|
|
188
|
+
const sub = randomBytes(16).toString('base64url');
|
|
189
|
+
pendingSessions.set(nonce, {
|
|
190
|
+
clientId,
|
|
191
|
+
redirectUri,
|
|
192
|
+
state,
|
|
193
|
+
codeChallenge,
|
|
194
|
+
codeChallengeMethod,
|
|
195
|
+
createdAt: Date.now(),
|
|
196
|
+
sub
|
|
197
|
+
});
|
|
198
|
+
pruneExpired(pendingSessions, SESSION_TTL_S * 1000);
|
|
199
|
+
const base = getBaseUrl(req);
|
|
200
|
+
const submitUrl = `${base}/authorize?nonce=${nonce}`;
|
|
201
|
+
const html = options.customCredentialFormHtml !== undefined
|
|
202
|
+
? options.customCredentialFormHtml(options.relaySchema, { submitUrl, prefill })
|
|
203
|
+
: renderCredentialForm(options.relaySchema, {
|
|
204
|
+
submitUrl,
|
|
205
|
+
prefill,
|
|
206
|
+
includeUsernameField: options.stableSubEnabled === true
|
|
207
|
+
});
|
|
208
|
+
htmlResponse(res, 200, html);
|
|
209
|
+
}
|
|
210
|
+
async function authorizePost(req, res) {
|
|
211
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
212
|
+
const nonce = url.searchParams.get('nonce');
|
|
213
|
+
if (!nonce || !pendingSessions.has(nonce)) {
|
|
214
|
+
jsonResponse(res, 400, {
|
|
215
|
+
error: 'invalid_request',
|
|
216
|
+
error_description: 'Invalid or expired nonce'
|
|
217
|
+
});
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const session = pendingSessions.get(nonce);
|
|
221
|
+
pendingSessions.delete(nonce);
|
|
222
|
+
if (Date.now() - session.createdAt > SESSION_TTL_S * 1000) {
|
|
223
|
+
jsonResponse(res, 400, {
|
|
224
|
+
error: 'invalid_request',
|
|
225
|
+
error_description: 'Session expired'
|
|
226
|
+
});
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
let credentials;
|
|
230
|
+
try {
|
|
231
|
+
credentials = await parseJsonBody(req);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
jsonResponse(res, 400, {
|
|
235
|
+
error: 'invalid_request',
|
|
236
|
+
error_description: 'Invalid JSON body'
|
|
237
|
+
});
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
// Reserved partition key: remove it so it is never persisted as a provider
|
|
241
|
+
// credential. When stable-sub is enabled and a username was supplied,
|
|
242
|
+
// override the random per-authorize sub with a STABLE one derived from it
|
|
243
|
+
// (same username -> same per-sub bucket across re-auth and devices).
|
|
244
|
+
// Blank username or disabled flag -> keep the random sub (unchanged).
|
|
245
|
+
const submittedUsername = String(credentials.__sub_username ?? '').trim();
|
|
246
|
+
delete credentials.__sub_username;
|
|
247
|
+
if (options.stableSubEnabled === true && submittedUsername) {
|
|
248
|
+
session.sub = deriveStableSub(submittedUsername, options.serverName, process.env.CREDENTIAL_SECRET);
|
|
249
|
+
}
|
|
250
|
+
// Reset stale completion markers from previous authorize submits.
|
|
251
|
+
// setupStatus is module-scoped, so a key flipped to "complete" by a
|
|
252
|
+
// prior background poll (e.g. Outlook device code finished on the
|
|
253
|
+
// first attempt) would otherwise persist into the next form submit.
|
|
254
|
+
// The frontend renders a fresh oauth_device_code UI and starts
|
|
255
|
+
// polling /setup-status, which returns the stale "complete" within
|
|
256
|
+
// a few seconds and triggers a premature redirect — the user sees
|
|
257
|
+
// the device code flash on screen and then the "setup complete"
|
|
258
|
+
// banner before they ever open microsoft.com/link. Reset all keys
|
|
259
|
+
// back to "idle" here so each submit starts from a clean state.
|
|
260
|
+
for (const key of Object.keys(setupStatus)) {
|
|
261
|
+
setupStatus[key] = 'idle';
|
|
262
|
+
}
|
|
263
|
+
// Save credentials via callback. Callback may return a dict with
|
|
264
|
+
// next_step info (e.g. GDrive OAuth device code to show in the form).
|
|
265
|
+
// The per-authorize ``sub`` is threaded through so consumers persist
|
|
266
|
+
// credentials keyed by subject — subsequently the JWT issued at /token
|
|
267
|
+
// will carry this same sub, letting tool handlers load the correct
|
|
268
|
+
// credential set via AsyncLocalStorage.
|
|
269
|
+
let nextStep = null;
|
|
270
|
+
if (options.onCredentialsSaved !== undefined) {
|
|
271
|
+
try {
|
|
272
|
+
const result = await options.onCredentialsSaved(credentials, { sub: session.sub });
|
|
273
|
+
if (result !== null && result !== undefined && typeof result === 'object') {
|
|
274
|
+
nextStep = result;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
jsonResponse(res, 500, {
|
|
279
|
+
error: 'server_error',
|
|
280
|
+
error_description: 'Failed to save credentials'
|
|
281
|
+
});
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// Issue #682: a failing save callback must not yield an auth code. The
|
|
286
|
+
// response used to carry both ``redirect_url`` (with a usable code) and
|
|
287
|
+
// ``next_step: {type: 'error'}``, so a client reading only the redirect
|
|
288
|
+
// exchanged the code for a token bound to credentials that were never
|
|
289
|
+
// persisted. Short-circuit here, before the code is minted. Other
|
|
290
|
+
// next_step types are legitimate continuations of a successful save and
|
|
291
|
+
// keep the redirect.
|
|
292
|
+
if (nextStep !== null && nextStep.type === 'error') {
|
|
293
|
+
const errText = typeof nextStep.text === 'string' ? nextStep.text : 'credential save failed';
|
|
294
|
+
jsonResponse(res, 400, { ok: false, error: errText });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
// Mark the persistent ``_setup_complete`` flag once the user submits
|
|
298
|
+
// successfully. Multi-step flows (OTP / 2FA) defer marking until the
|
|
299
|
+
// final step in ``otpHandler`` — see core-py parity. Best-effort: any
|
|
300
|
+
// storage error is logged via warning rather than failing the response.
|
|
301
|
+
const isMultiStep = nextStep !== null && (nextStep.type === 'otp_required' || nextStep.type === 'password_required');
|
|
302
|
+
if (!isMultiStep) {
|
|
303
|
+
try {
|
|
304
|
+
await markConfigSetupComplete(options.serverName);
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
console.warn('Failed to mark _setup_complete=true for %s: %s', options.serverName, err instanceof Error ? err.message : String(err));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// Generate auth code. Carry ``sub`` so /token can issue JWT with the
|
|
311
|
+
// same subject the credentials were saved under.
|
|
312
|
+
const authCode = randomBytes(32).toString('base64url');
|
|
313
|
+
authCodes.set(authCode, {
|
|
314
|
+
codeChallenge: session.codeChallenge,
|
|
315
|
+
codeChallengeMethod: session.codeChallengeMethod,
|
|
316
|
+
createdAt: Date.now(),
|
|
317
|
+
sub: session.sub
|
|
318
|
+
});
|
|
319
|
+
pruneExpired(authCodes, AUTH_CODE_TTL_S * 1000);
|
|
320
|
+
// RFC 9207 / SEP-2468: carry ``iss`` (this AS's issuer) in the
|
|
321
|
+
// authorization response so the client can verify the response came from
|
|
322
|
+
// the AS it started the flow with (mix-up defence).
|
|
323
|
+
const base = getBaseUrl(req);
|
|
324
|
+
const separator = session.redirectUri.includes('?') ? '&' : '?';
|
|
325
|
+
const redirectUrl = `${session.redirectUri}${separator}code=${authCode}&state=${session.state}` + `&iss=${encodeURIComponent(base)}`;
|
|
326
|
+
const body = { ok: true, redirect_url: redirectUrl };
|
|
327
|
+
if (nextStep !== null) {
|
|
328
|
+
body.next_step = nextStep;
|
|
329
|
+
// If next_step requires additional input (OTP or 2FA password),
|
|
330
|
+
// activate pending step session so /otp endpoint accepts input.
|
|
331
|
+
// Capture the authorize-session sub so /otp can thread the correct
|
|
332
|
+
// SubjectContext into onStepSubmitted (the browser POSTs to /otp
|
|
333
|
+
// with step data only — no sub in body).
|
|
334
|
+
const stepType = nextStep.type;
|
|
335
|
+
if (stepType === 'otp_required' || stepType === 'password_required') {
|
|
336
|
+
markPendingStep(session.sub);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
jsonResponse(res, 200, body);
|
|
340
|
+
}
|
|
341
|
+
async function authorize(req, res) {
|
|
342
|
+
if (req.method === 'GET') {
|
|
343
|
+
await authorizeGet(req, res);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
await authorizePost(req, res);
|
|
347
|
+
}
|
|
348
|
+
async function authorizePrefill(req, res) {
|
|
349
|
+
// Driver-only side-channel: store form prefill values keyed by the OAuth
|
|
350
|
+
// ``state`` token chosen by the client. ``GET /authorize?state=<X>`` then
|
|
351
|
+
// hydrates the form on render — credentials never appear in the URL.
|
|
352
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
353
|
+
const state = url.searchParams.get('state');
|
|
354
|
+
if (!state) {
|
|
355
|
+
jsonResponse(res, 400, { error: 'invalid_request', error_description: 'Missing state' });
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
let body;
|
|
359
|
+
try {
|
|
360
|
+
body = await parseJsonBody(req);
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
jsonResponse(res, 400, { error: 'invalid_request', error_description: 'Body must be JSON object' });
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
367
|
+
jsonResponse(res, 400, { error: 'invalid_request', error_description: 'Body must be JSON object' });
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
// Coerce all values to string + drop empties so blank ``value=""`` attrs
|
|
371
|
+
// don't shadow the placeholder text in the rendered form.
|
|
372
|
+
const data = {};
|
|
373
|
+
for (const [k, v] of Object.entries(body)) {
|
|
374
|
+
if (v != null && String(v).length > 0) {
|
|
375
|
+
data[k] = String(v);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
pendingPrefills.set(state, { data, createdAt: Date.now() });
|
|
379
|
+
pruneExpired(pendingPrefills, PREFILL_TTL_S * 1000);
|
|
380
|
+
jsonResponse(res, 204, {});
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Build and write the standard /token success body for ``sub``.
|
|
384
|
+
*
|
|
385
|
+
* Issues a fresh access token AND a fresh refresh token. The refresh token
|
|
386
|
+
* lets long-running MCP clients renew the 1h access token without re-running
|
|
387
|
+
* the browser PKCE flow (issue #261). ``scope`` advertises ``offline_access``
|
|
388
|
+
* so clients know a refresh token was granted.
|
|
389
|
+
*/
|
|
390
|
+
async function issueTokenResponse(res, sub) {
|
|
391
|
+
const accessToken = await jwtIssuer.issueAccessToken(sub);
|
|
392
|
+
const refreshToken = await jwtIssuer.issueRefreshToken(sub);
|
|
393
|
+
jsonResponse(res, 200, {
|
|
394
|
+
access_token: accessToken,
|
|
395
|
+
token_type: 'Bearer',
|
|
396
|
+
expires_in: 3600,
|
|
397
|
+
refresh_token: refreshToken,
|
|
398
|
+
scope: 'offline_access'
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Handle ``grant_type=refresh_token`` (RFC 6749 §6) statelessly. Verifies the
|
|
403
|
+
* presented refresh token's signature / iss / aud / exp / ``typ`` via the JWT
|
|
404
|
+
* issuer (no server-side store), extracts ``sub``, and issues a NEW access
|
|
405
|
+
* token AND a NEW refresh token (rotation). The refresh token is
|
|
406
|
+
* self-contained, so rotation here is stateless: the old refresh token simply
|
|
407
|
+
* expires on its own 30-day clock; clients replace it with the rotated one.
|
|
408
|
+
*/
|
|
409
|
+
async function handleRefreshToken(res, form) {
|
|
410
|
+
const refreshToken = form.refresh_token;
|
|
411
|
+
if (!refreshToken) {
|
|
412
|
+
jsonResponse(res, 400, {
|
|
413
|
+
error: 'invalid_request',
|
|
414
|
+
error_description: 'Missing refresh_token'
|
|
415
|
+
});
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
let sub;
|
|
419
|
+
try {
|
|
420
|
+
const claims = await jwtIssuer.verifyRefreshToken(refreshToken);
|
|
421
|
+
sub = claims.sub;
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
jsonResponse(res, 400, { error: 'invalid_grant' });
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
await issueTokenResponse(res, sub);
|
|
428
|
+
}
|
|
429
|
+
async function token(req, res) {
|
|
430
|
+
let form;
|
|
431
|
+
try {
|
|
432
|
+
form = await parseFormBody(req);
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
jsonResponse(res, 400, { error: 'invalid_request' });
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const grantType = form.grant_type;
|
|
439
|
+
if (grantType === 'refresh_token') {
|
|
440
|
+
await handleRefreshToken(res, form);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (grantType !== 'authorization_code') {
|
|
444
|
+
jsonResponse(res, 400, { error: 'unsupported_grant_type' });
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const code = form.code;
|
|
448
|
+
const codeVerifier = form.code_verifier;
|
|
449
|
+
if (!code || !codeVerifier) {
|
|
450
|
+
jsonResponse(res, 400, {
|
|
451
|
+
error: 'invalid_request',
|
|
452
|
+
error_description: 'Missing code or code_verifier'
|
|
453
|
+
});
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const entry = authCodes.get(code);
|
|
457
|
+
if (entry === undefined) {
|
|
458
|
+
jsonResponse(res, 400, { error: 'invalid_grant' });
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
authCodes.delete(code);
|
|
462
|
+
if (Date.now() - entry.createdAt > AUTH_CODE_TTL_S * 1000) {
|
|
463
|
+
jsonResponse(res, 400, { error: 'invalid_grant' });
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (entry.codeChallengeMethod !== 'S256') {
|
|
467
|
+
jsonResponse(res, 400, {
|
|
468
|
+
error: 'invalid_request',
|
|
469
|
+
error_description: 'Only S256 is supported'
|
|
470
|
+
});
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (!s256Verify(codeVerifier, entry.codeChallenge)) {
|
|
474
|
+
jsonResponse(res, 400, { error: 'invalid_grant' });
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
// Issue JWT with the subject bound to this authorize session. Historically
|
|
478
|
+
// this was the static string 'local-user' — that collapsed every concurrent
|
|
479
|
+
// browser into one subject and made credential isolation impossible. The
|
|
480
|
+
// new flow mints a fresh UUID in authorizeGet (PendingSession.sub), carries
|
|
481
|
+
// it through onCredentialsSaved, and issues it here so the Bearer returned
|
|
482
|
+
// to the client scopes future /mcp calls to this user's credentials.
|
|
483
|
+
await issueTokenResponse(res, entry.sub);
|
|
484
|
+
}
|
|
485
|
+
async function otpHandler(req, res) {
|
|
486
|
+
// 1. Active session check.
|
|
487
|
+
if (pendingStep === null || !pendingStep.active) {
|
|
488
|
+
jsonResponse(res, 400, {
|
|
489
|
+
error: 'invalid_request',
|
|
490
|
+
error_description: 'No active step session'
|
|
491
|
+
});
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
// 2. Timeout check.
|
|
495
|
+
if (Date.now() - pendingStep.createdAt > OTP_TIMEOUT_S * 1000) {
|
|
496
|
+
clearPendingStep();
|
|
497
|
+
jsonResponse(res, 400, {
|
|
498
|
+
error: 'invalid_request',
|
|
499
|
+
error_description: 'Step session expired'
|
|
500
|
+
});
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
// 3. Parse JSON body BEFORE incrementing attempts. Malformed input
|
|
504
|
+
// must not consume the user's retry quota nor clear the session.
|
|
505
|
+
let stepData;
|
|
506
|
+
try {
|
|
507
|
+
stepData = await parseJsonBody(req);
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
jsonResponse(res, 400, {
|
|
511
|
+
error: 'invalid_request',
|
|
512
|
+
error_description: 'Invalid JSON body'
|
|
513
|
+
});
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
// 4. Increment attempts counter (count every valid-JSON submit).
|
|
517
|
+
pendingStep.attempts += 1;
|
|
518
|
+
// 5. Attempt limit check.
|
|
519
|
+
if (pendingStep.attempts > OTP_MAX_ATTEMPTS) {
|
|
520
|
+
clearPendingStep();
|
|
521
|
+
jsonResponse(res, 400, {
|
|
522
|
+
error: 'invalid_request',
|
|
523
|
+
error_description: 'Too many attempts'
|
|
524
|
+
});
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
// 6. Dispatch to step callback with the authorize-session sub so
|
|
528
|
+
// consumers (telegram Telethon multi-user) can route to the correct
|
|
529
|
+
// per-user state.
|
|
530
|
+
const stepContext = { sub: pendingStep.sub };
|
|
531
|
+
const stepSub = pendingStep.sub;
|
|
532
|
+
let nextStep = null;
|
|
533
|
+
if (options.onStepSubmitted !== undefined) {
|
|
534
|
+
try {
|
|
535
|
+
const result = await options.onStepSubmitted(stepData, stepContext);
|
|
536
|
+
if (result !== null && result !== undefined && typeof result === 'object') {
|
|
537
|
+
nextStep = result;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
catch {
|
|
541
|
+
jsonResponse(res, 500, {
|
|
542
|
+
error: 'server_error',
|
|
543
|
+
error_description: 'Failed to process step input'
|
|
544
|
+
});
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
// Error from callback: keep pending session, allow retry.
|
|
549
|
+
if (nextStep !== null && nextStep.type === 'error') {
|
|
550
|
+
const errText = typeof nextStep.text === 'string' ? nextStep.text : 'Invalid input';
|
|
551
|
+
jsonResponse(res, 200, { ok: false, error: errText });
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
// Chain to next step: reset counters so the new step gets its own quota.
|
|
555
|
+
// Preserve the original sub so the whole multi-step chain belongs to the
|
|
556
|
+
// same user.
|
|
557
|
+
if (nextStep !== null && (nextStep.type === 'otp_required' || nextStep.type === 'password_required')) {
|
|
558
|
+
markPendingStep(stepSub);
|
|
559
|
+
jsonResponse(res, 200, { ok: true, next_step: nextStep });
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
// Completion (callback returned null / undefined or unknown dict type).
|
|
563
|
+
// Mark persistent _setup_complete flag now that the multi-step chain has
|
|
564
|
+
// finished — single-step counterpart lives in authorizePost.
|
|
565
|
+
try {
|
|
566
|
+
await markConfigSetupComplete(options.serverName);
|
|
567
|
+
}
|
|
568
|
+
catch (err) {
|
|
569
|
+
console.warn('Failed to mark _setup_complete=true for %s: %s', options.serverName, err instanceof Error ? err.message : String(err));
|
|
570
|
+
}
|
|
571
|
+
clearPendingStep();
|
|
572
|
+
jsonResponse(res, 200, { ok: true });
|
|
573
|
+
}
|
|
574
|
+
async function setupStatusHandler(_req, res) {
|
|
575
|
+
jsonResponse(res, 200, setupStatus);
|
|
576
|
+
}
|
|
577
|
+
async function wellKnownAs(req, res) {
|
|
578
|
+
const base = getBaseUrl(req);
|
|
579
|
+
jsonResponse(res, 200, authorizationServerMetadata(base));
|
|
580
|
+
}
|
|
581
|
+
async function wellKnownPr(req, res) {
|
|
582
|
+
const base = getBaseUrl(req);
|
|
583
|
+
jsonResponse(res, 200, protectedResourceMetadata(base, [base]));
|
|
584
|
+
}
|
|
585
|
+
async function wellKnownJwks(_req, res) {
|
|
586
|
+
jsonResponse(res, 200, await jwtIssuer.getJwks());
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* RFC 7591 Dynamic Client Registration.
|
|
590
|
+
*
|
|
591
|
+
* Local OAuth server uses a fixed public client id (`local-browser`).
|
|
592
|
+
* DCR is echo-style — mirror the client's submitted metadata back with
|
|
593
|
+
* the fixed id so MCP clients (Python SDK OAuthClientProvider, etc.)
|
|
594
|
+
* can bootstrap OAuth without failing at the registration step.
|
|
595
|
+
*/
|
|
596
|
+
async function registerHandler(req, res) {
|
|
597
|
+
let body = {};
|
|
598
|
+
try {
|
|
599
|
+
const raw = await parseJsonBody(req);
|
|
600
|
+
body = raw;
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
// fall through with empty body
|
|
604
|
+
}
|
|
605
|
+
const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris : [];
|
|
606
|
+
const grantTypes = Array.isArray(body.grant_types) ? body.grant_types : ['authorization_code'];
|
|
607
|
+
const responseTypes = Array.isArray(body.response_types) ? body.response_types : ['code'];
|
|
608
|
+
const clientName = typeof body.client_name === 'string' ? body.client_name : 'mcp-client';
|
|
609
|
+
jsonResponse(res, 201, {
|
|
610
|
+
client_id: 'local-browser',
|
|
611
|
+
client_name: clientName,
|
|
612
|
+
redirect_uris: redirectUris,
|
|
613
|
+
grant_types: grantTypes,
|
|
614
|
+
response_types: responseTypes,
|
|
615
|
+
token_endpoint_auth_method: 'none'
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
function markSetupComplete(key = 'gdrive') {
|
|
619
|
+
setupStatus[key] = 'complete';
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Mark a background setup step as failed. The status becomes
|
|
623
|
+
* ``"error:<message>"`` so the frontend poll handler can surface the
|
|
624
|
+
* message and stop spinning. Whitespace is collapsed to keep the value
|
|
625
|
+
* single-line (the frontend inlines it verbatim).
|
|
626
|
+
*/
|
|
627
|
+
function markSetupFailed(key = 'gdrive', error = 'unknown error') {
|
|
628
|
+
// Replace multiple array allocations (.split.filter.join) with regex replace for faster whitespace collapsing
|
|
629
|
+
const collapsed = String(error).replace(/\s+/g, ' ').trim();
|
|
630
|
+
const message = collapsed.length > 0 ? collapsed : 'unknown error';
|
|
631
|
+
setupStatus[key] = `error:${message}`;
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* GET / -- auto-generate PKCE and redirect to /authorize.
|
|
635
|
+
*
|
|
636
|
+
* The ``/authorize`` endpoint requires 4 PKCE parameters; users arriving
|
|
637
|
+
* from a log line / bookmark have no way to construct them. Bootstrap a
|
|
638
|
+
* default ``local-browser`` client here: generate random state + S256
|
|
639
|
+
* challenge, redirect to ``/authorize``, and on success return to
|
|
640
|
+
* ``/callback-done`` for a friendly close message. Keeps the one-URL UX
|
|
641
|
+
* ("open http://... in browser") working without exposing the raw OAuth
|
|
642
|
+
* machinery to end users.
|
|
643
|
+
*/
|
|
644
|
+
async function rootHandler(req, res) {
|
|
645
|
+
const base = getBaseUrl(req);
|
|
646
|
+
const codeVerifier = randomBytes(64).toString('base64url');
|
|
647
|
+
const codeChallenge = createHash('sha256').update(codeVerifier, 'ascii').digest('base64url');
|
|
648
|
+
const state = randomBytes(16).toString('base64url');
|
|
649
|
+
const params = new URLSearchParams({
|
|
650
|
+
client_id: 'local-browser',
|
|
651
|
+
redirect_uri: `${base}/callback-done`,
|
|
652
|
+
state,
|
|
653
|
+
code_challenge: codeChallenge,
|
|
654
|
+
code_challenge_method: 'S256'
|
|
655
|
+
});
|
|
656
|
+
res.writeHead(302, { Location: `/authorize?${params.toString()}` });
|
|
657
|
+
res.end();
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* GET /callback-done -- terminal "tab can be closed" landing page.
|
|
661
|
+
*
|
|
662
|
+
* Users redirected from the bootstrap flow land here on success. Exists
|
|
663
|
+
* purely so the bare URL doesn't 404 after the PKCE redirect completes.
|
|
664
|
+
*/
|
|
665
|
+
async function callbackDoneHandler(_req, res) {
|
|
666
|
+
const html = "<!DOCTYPE html><html><head><meta charset='utf-8'>" +
|
|
667
|
+
'<title>Setup complete</title>' +
|
|
668
|
+
'<style>body{font-family:-apple-system,Segoe UI,sans-serif;' +
|
|
669
|
+
'background:#111;color:#eee;display:flex;align-items:center;' +
|
|
670
|
+
'justify-content:center;height:100vh;margin:0}' +
|
|
671
|
+
'.box{text-align:center;padding:2rem;border:1px solid #333;' +
|
|
672
|
+
'border-radius:8px;background:#1a1a1a}' +
|
|
673
|
+
'h1{color:#34c759;margin:0 0 0.5rem}p{color:#aaa;margin:0}' +
|
|
674
|
+
'</style></head><body><div class="box">' +
|
|
675
|
+
'<h1>Setup complete</h1>' +
|
|
676
|
+
'<p>You can close this tab.</p>' +
|
|
677
|
+
'</div></body></html>';
|
|
678
|
+
htmlResponse(res, 200, html);
|
|
679
|
+
}
|
|
680
|
+
// ------------------------------------------------------------------
|
|
681
|
+
// Relay password gate adapters.
|
|
682
|
+
//
|
|
683
|
+
// The relay-login module exposes Express-style handlers (``req.body``,
|
|
684
|
+
// ``res.status().send()`` etc) so the same logic can be unit-tested
|
|
685
|
+
// without a live HTTP server. Here we adapt them onto the project's
|
|
686
|
+
// ``IncomingMessage`` / ``ServerResponse`` router. Each adapter:
|
|
687
|
+
// 1. Parses ``Cookie`` into ``req.cookies``,
|
|
688
|
+
// 2. Mounts ``res.status / res.send / res.cookie / res.redirect /
|
|
689
|
+
// res.header / res.set`` on top of ``res.writeHead`` / ``res.end``,
|
|
690
|
+
// 3. Lets the relay-login handler do the work.
|
|
691
|
+
//
|
|
692
|
+
// Only ``/authorize`` GET + POST + ``/authorize/prefill`` go through
|
|
693
|
+
// ``relayMw``; ``/token``, ``/register``, ``/otp``, ``/setup-status``,
|
|
694
|
+
// ``/.well-known/*``, ``/`` and ``/callback-done`` are intentionally NOT
|
|
695
|
+
// gated — they're machine endpoints (DCR / token exchange / metadata) or
|
|
696
|
+
// auto-bootstrap helpers that must remain accessible without the cookie.
|
|
697
|
+
// ------------------------------------------------------------------
|
|
698
|
+
function parseCookies(req) {
|
|
699
|
+
const header = req.headers.cookie;
|
|
700
|
+
if (!header)
|
|
701
|
+
return {};
|
|
702
|
+
const out = {};
|
|
703
|
+
// Use a single-pass loop instead of `split(';')` to avoid array
|
|
704
|
+
// allocations and intermediate string objects in this hot path.
|
|
705
|
+
let pos = 0;
|
|
706
|
+
while (pos < header.length) {
|
|
707
|
+
let nextSemi = header.indexOf(';', pos);
|
|
708
|
+
if (nextSemi < 0)
|
|
709
|
+
nextSemi = header.length;
|
|
710
|
+
const eq = header.indexOf('=', pos);
|
|
711
|
+
if (eq >= 0 && eq < nextSemi) {
|
|
712
|
+
const k = header.substring(pos, eq).trim();
|
|
713
|
+
const v = header.substring(eq + 1, nextSemi).trim();
|
|
714
|
+
if (k.length > 0)
|
|
715
|
+
out[k] = decodeURIComponent(v);
|
|
716
|
+
}
|
|
717
|
+
pos = nextSemi + 1;
|
|
718
|
+
}
|
|
719
|
+
return out;
|
|
720
|
+
}
|
|
721
|
+
function clientIp(req) {
|
|
722
|
+
const fwd = req.headers['x-forwarded-for'];
|
|
723
|
+
if (typeof fwd === 'string' && fwd.length > 0) {
|
|
724
|
+
const idx = fwd.indexOf(',');
|
|
725
|
+
return (idx !== -1 ? fwd.substring(0, idx) : fwd).trim();
|
|
726
|
+
}
|
|
727
|
+
return req.socket.remoteAddress ?? 'unknown';
|
|
728
|
+
}
|
|
729
|
+
function buildResAdapter(res) {
|
|
730
|
+
let statusCode = 200;
|
|
731
|
+
const headers = {};
|
|
732
|
+
let completed = false;
|
|
733
|
+
const setHeader = (name, value) => {
|
|
734
|
+
headers[name] = value;
|
|
735
|
+
};
|
|
736
|
+
const adapter = {
|
|
737
|
+
status: (code) => {
|
|
738
|
+
statusCode = Number(code);
|
|
739
|
+
return adapter;
|
|
740
|
+
},
|
|
741
|
+
header: (name, value) => {
|
|
742
|
+
setHeader(String(name), String(value));
|
|
743
|
+
return adapter;
|
|
744
|
+
},
|
|
745
|
+
set: (name, value) => {
|
|
746
|
+
setHeader(String(name), String(value));
|
|
747
|
+
return adapter;
|
|
748
|
+
},
|
|
749
|
+
cookie: (name, value, options) => {
|
|
750
|
+
const opts = (options ?? {});
|
|
751
|
+
const parts = [`${String(name)}=${String(value)}`];
|
|
752
|
+
if (opts.maxAge !== undefined) {
|
|
753
|
+
parts.push(`Max-Age=${Math.floor(Number(opts.maxAge) / 1000)}`);
|
|
754
|
+
}
|
|
755
|
+
if (opts.httpOnly === true)
|
|
756
|
+
parts.push('HttpOnly');
|
|
757
|
+
if (opts.secure === true)
|
|
758
|
+
parts.push('Secure');
|
|
759
|
+
if (typeof opts.sameSite === 'string') {
|
|
760
|
+
// Capitalise SameSite values (lax → Lax, strict → Strict).
|
|
761
|
+
const v = opts.sameSite;
|
|
762
|
+
parts.push(`SameSite=${v.charAt(0).toUpperCase() + v.slice(1)}`);
|
|
763
|
+
}
|
|
764
|
+
parts.push('Path=/');
|
|
765
|
+
const existing = headers['Set-Cookie'];
|
|
766
|
+
const next = parts.join('; ');
|
|
767
|
+
headers['Set-Cookie'] = existing !== undefined ? `${existing}, ${next}` : next;
|
|
768
|
+
return adapter;
|
|
769
|
+
},
|
|
770
|
+
send: (body) => {
|
|
771
|
+
if (completed)
|
|
772
|
+
return adapter;
|
|
773
|
+
completed = true;
|
|
774
|
+
if (!('Content-Type' in headers)) {
|
|
775
|
+
headers['Content-Type'] = 'text/html; charset=utf-8';
|
|
776
|
+
}
|
|
777
|
+
// Anti-clickjacking for HTML responses
|
|
778
|
+
const ctype = headers['Content-Type'];
|
|
779
|
+
if (ctype && ctype.toLowerCase().includes('text/html')) {
|
|
780
|
+
headers['X-Frame-Options'] = 'DENY';
|
|
781
|
+
}
|
|
782
|
+
res.writeHead(statusCode, headers);
|
|
783
|
+
res.end(body === undefined ? '' : String(body));
|
|
784
|
+
return adapter;
|
|
785
|
+
},
|
|
786
|
+
redirect: (url) => {
|
|
787
|
+
if (completed)
|
|
788
|
+
return adapter;
|
|
789
|
+
completed = true;
|
|
790
|
+
headers.Location = String(url);
|
|
791
|
+
res.writeHead(302, headers);
|
|
792
|
+
res.end();
|
|
793
|
+
return adapter;
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
return { adapter, isComplete: () => completed };
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Wrap a router handler with the relay-login middleware. If the cookie
|
|
800
|
+
* gate redirects, the inner handler is never called; otherwise the inner
|
|
801
|
+
* handler runs against the original ``req`` / ``res``.
|
|
802
|
+
*/
|
|
803
|
+
function withRelayGate(inner) {
|
|
804
|
+
return async (req, res) => {
|
|
805
|
+
if (!relayPassword) {
|
|
806
|
+
await inner(req, res);
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
const cookies = parseCookies(req);
|
|
810
|
+
const expressReq = {
|
|
811
|
+
cookies,
|
|
812
|
+
originalUrl: req.url ?? '/'
|
|
813
|
+
};
|
|
814
|
+
const { adapter, isComplete } = buildResAdapter(res);
|
|
815
|
+
let proceed = false;
|
|
816
|
+
await relayMw(expressReq, adapter, () => {
|
|
817
|
+
proceed = true;
|
|
818
|
+
});
|
|
819
|
+
if (proceed && !isComplete()) {
|
|
820
|
+
await inner(req, res);
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Adapter: bind ``loginGetHandler`` / ``loginPostHandler`` (Express-style)
|
|
826
|
+
* onto IncomingMessage / ServerResponse routes.
|
|
827
|
+
*/
|
|
828
|
+
async function loginGetRoute(req, res) {
|
|
829
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
830
|
+
const expressReq = { query: { next: url.searchParams.get('next') ?? '/authorize' } };
|
|
831
|
+
const { adapter } = buildResAdapter(res);
|
|
832
|
+
await loginGetHandler(expressReq, adapter);
|
|
833
|
+
}
|
|
834
|
+
async function loginPostRoute(req, res) {
|
|
835
|
+
let body = {};
|
|
836
|
+
try {
|
|
837
|
+
body = await parseFormBody(req);
|
|
838
|
+
}
|
|
839
|
+
catch {
|
|
840
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
841
|
+
res.end(JSON.stringify({ error: 'invalid_request' }));
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
const expressReq = { body, ip: clientIp(req) };
|
|
845
|
+
const { adapter } = buildResAdapter(res);
|
|
846
|
+
await loginPostHandler(expressReq, adapter);
|
|
847
|
+
}
|
|
848
|
+
const handler = createRouter([
|
|
849
|
+
{ method: 'GET', path: '/', handler: rootHandler },
|
|
850
|
+
{ method: 'GET', path: '/login', handler: loginGetRoute },
|
|
851
|
+
{ method: 'POST', path: '/login', handler: loginPostRoute },
|
|
852
|
+
{ method: 'GET', path: '/authorize', handler: withRelayGate(authorize) },
|
|
853
|
+
{ method: 'POST', path: '/authorize', handler: withRelayGate(authorize) },
|
|
854
|
+
{ method: 'POST', path: '/authorize/prefill', handler: withRelayGate(authorizePrefill) },
|
|
855
|
+
{ method: 'POST', path: '/token', handler: token },
|
|
856
|
+
{ method: 'POST', path: '/register', handler: registerHandler },
|
|
857
|
+
{ method: 'POST', path: '/otp', handler: otpHandler },
|
|
858
|
+
{ method: 'GET', path: '/setup-status', handler: setupStatusHandler },
|
|
859
|
+
{ method: 'GET', path: '/callback-done', handler: callbackDoneHandler },
|
|
860
|
+
{ method: 'GET', path: '/.well-known/oauth-authorization-server', handler: wellKnownAs },
|
|
861
|
+
{ method: 'GET', path: '/.well-known/oauth-protected-resource', handler: wellKnownPr },
|
|
862
|
+
{ method: 'GET', path: '/.well-known/jwks.json', handler: wellKnownJwks }
|
|
863
|
+
]);
|
|
864
|
+
return { handler, jwtIssuer, markSetupComplete, markSetupFailed };
|
|
865
|
+
}
|
|
866
|
+
// ---------------------------------------------------------------------------
|
|
867
|
+
// D7 — Pre-fill renderer + form-submission merger.
|
|
868
|
+
//
|
|
869
|
+
// These helpers mirror the core-py exports of the same names. They are
|
|
870
|
+
// intentionally NOT wired into the default ``authorizeGet`` / ``authorizePost``
|
|
871
|
+
// above — that path stays on ``renderCredentialForm`` for legacy parity. New
|
|
872
|
+
// consumers (transparent-bridge Wave 1) compose ``renderField`` per field for
|
|
873
|
+
// secret-aware rendering, then call ``mergeSubmission`` to merge the POST body
|
|
874
|
+
// with their stored ``config.enc`` so a missing secret in the body preserves
|
|
875
|
+
// the previously stored value instead of clearing it.
|
|
876
|
+
// ---------------------------------------------------------------------------
|
|
877
|
+
const SECRET_PLACEHOLDER = '••••••••(configured)';
|
|
878
|
+
function escapeHtml(s) {
|
|
879
|
+
return s
|
|
880
|
+
.replace(/&/g, '&')
|
|
881
|
+
.replace(/</g, '<')
|
|
882
|
+
.replace(/>/g, '>')
|
|
883
|
+
.replace(/"/g, '"')
|
|
884
|
+
.replace(/'/g, ''');
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Resolve the canonical config key for a relay field.
|
|
888
|
+
*
|
|
889
|
+
* Two schema styles coexist in the codebase: D7 ``RelayConfigField`` uses
|
|
890
|
+
* ``name``; legacy ``ConfigField`` (consumed by ``renderCredentialForm``)
|
|
891
|
+
* uses ``key``. ``renderField`` and ``mergeSubmission`` accept both so a
|
|
892
|
+
* consumer can mix or migrate without rewriting field dicts. ``name`` wins
|
|
893
|
+
* when both are present.
|
|
894
|
+
*/
|
|
895
|
+
function fieldName(field) {
|
|
896
|
+
const raw = field;
|
|
897
|
+
const name = typeof raw.name === 'string' ? raw.name : '';
|
|
898
|
+
if (name.length > 0)
|
|
899
|
+
return name;
|
|
900
|
+
const key = typeof raw.key === 'string' ? raw.key : '';
|
|
901
|
+
return key;
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Render an HTML ``<label>+<input>`` for a single relay field.
|
|
905
|
+
*
|
|
906
|
+
* Rules per D7:
|
|
907
|
+
* - oauth_field: render Re-authorize button (no plaintext exposed)
|
|
908
|
+
* - secret + value present: placeholder, empty input, "Replace" checkbox
|
|
909
|
+
* - secret + no value: empty input with field label as placeholder
|
|
910
|
+
* - non-secret: pre-fill with ``currentValue`` as input ``value`` attr
|
|
911
|
+
*/
|
|
912
|
+
export function renderField(field, currentValue) {
|
|
913
|
+
const name = fieldName(field);
|
|
914
|
+
const label = field.label ?? name;
|
|
915
|
+
const fieldType = field.type ?? 'text';
|
|
916
|
+
if (isOAuthField(field)) {
|
|
917
|
+
const status = currentValue ? 'Connected' : 'Not connected';
|
|
918
|
+
return (`<div class="field oauth-field"><label>${escapeHtml(label)}</label> ` +
|
|
919
|
+
`<span class="oauth-status">${status}</span> ` +
|
|
920
|
+
`<button type="button" class="oauth-reauth" data-field="${escapeHtml(name)}" aria-label="Re-authorize ${escapeHtml(label)}">` +
|
|
921
|
+
`Re-authorize</button></div>`);
|
|
922
|
+
}
|
|
923
|
+
if (isSecretField(field)) {
|
|
924
|
+
if (currentValue) {
|
|
925
|
+
return (`<div class="field secret-field"><label for="field-${escapeHtml(name)}">${escapeHtml(label)}</label> ` +
|
|
926
|
+
`<input id="field-${escapeHtml(name)}" type="password" name="${escapeHtml(name)}" value="" ` +
|
|
927
|
+
`placeholder="${escapeHtml(SECRET_PLACEHOLDER)}" ` +
|
|
928
|
+
`data-secret-configured="true"> ` +
|
|
929
|
+
`<label for="replace-${escapeHtml(name)}" class="replace-toggle"><input id="replace-${escapeHtml(name)}" type="checkbox" ` +
|
|
930
|
+
`name="__replace_${escapeHtml(name)}" value="1"> Replace this credential</label>` +
|
|
931
|
+
`</div>`);
|
|
932
|
+
}
|
|
933
|
+
return (`<div class="field secret-field"><label for="field-${escapeHtml(name)}">${escapeHtml(label)}</label> ` +
|
|
934
|
+
`<input id="field-${escapeHtml(name)}" type="password" name="${escapeHtml(name)}" value="" ` +
|
|
935
|
+
`placeholder="${escapeHtml(label)}"></div>`);
|
|
936
|
+
}
|
|
937
|
+
const valueAttr = currentValue !== null && currentValue !== undefined ? escapeHtml(String(currentValue)) : '';
|
|
938
|
+
return (`<div class="field"><label for="field-${escapeHtml(name)}">${escapeHtml(label)}</label> ` +
|
|
939
|
+
`<input id="field-${escapeHtml(name)}" type="${escapeHtml(fieldType)}" name="${escapeHtml(name)}" ` +
|
|
940
|
+
`value="${valueAttr}"></div>`);
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Merge a form submission into the current config per D7 rules.
|
|
944
|
+
*
|
|
945
|
+
* Behavior:
|
|
946
|
+
* - Empty secret -> preserve the existing value (avoid clearing on a
|
|
947
|
+
* re-submit where the user did not retype the credential).
|
|
948
|
+
* - Non-empty secret -> replace.
|
|
949
|
+
* - Non-secret -> always replace (including with an empty string, so the
|
|
950
|
+
* user can intentionally blank a field).
|
|
951
|
+
* - oauth_field -> ignored here; OAuth fields are managed by their own
|
|
952
|
+
* Re-authorize flow, never by raw form input.
|
|
953
|
+
*/
|
|
954
|
+
export function mergeSubmission(current, submitted, schemaFields) {
|
|
955
|
+
const result = { ...current };
|
|
956
|
+
for (const field of schemaFields) {
|
|
957
|
+
const name = fieldName(field);
|
|
958
|
+
if (!name)
|
|
959
|
+
continue;
|
|
960
|
+
if (isOAuthField(field))
|
|
961
|
+
continue;
|
|
962
|
+
const newValue = submitted[name] ?? '';
|
|
963
|
+
if (isSecretField(field)) {
|
|
964
|
+
if (newValue === '' || newValue === null || newValue === undefined) {
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
result[name] = newValue;
|
|
968
|
+
}
|
|
969
|
+
else {
|
|
970
|
+
result[name] = newValue;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
return result;
|
|
974
|
+
}
|
|
975
|
+
//# sourceMappingURL=local-oauth-app.js.map
|