@n24q02m/hull 0.4.0 → 0.5.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -16
- package/build/cli/build-cli.d.ts +5 -6
- package/build/cli/build-cli.d.ts.map +1 -1
- package/build/cli/build-cli.js +4 -61
- package/build/cli/build-cli.js.map +1 -1
- package/build/crypto/index.d.ts +1 -1
- package/build/crypto/index.d.ts.map +1 -1
- package/build/crypto/index.js +1 -1
- package/build/crypto/index.js.map +1 -1
- package/build/crypto/kdf.d.ts +0 -13
- package/build/crypto/kdf.d.ts.map +1 -1
- package/build/crypto/kdf.js +3 -18
- package/build/crypto/kdf.js.map +1 -1
- package/build/index.d.ts +1 -11
- package/build/index.d.ts.map +1 -1
- package/build/index.js +4 -16
- package/build/index.js.map +1 -1
- package/build/storage/account-registry.d.ts +53 -0
- package/build/storage/account-registry.d.ts.map +1 -0
- package/build/storage/account-registry.js +148 -0
- package/build/storage/account-registry.js.map +1 -0
- package/build/storage/backends.d.ts +0 -26
- package/build/storage/backends.d.ts.map +1 -1
- package/build/storage/backends.js +0 -65
- package/build/storage/backends.js.map +1 -1
- package/build/storage/config-file.d.ts +2 -19
- package/build/storage/config-file.d.ts.map +1 -1
- package/build/storage/config-file.js +2 -23
- package/build/storage/config-file.js.map +1 -1
- package/build/storage/credential-store.d.ts +1 -9
- package/build/storage/credential-store.d.ts.map +1 -1
- package/build/storage/credential-store.js +4 -16
- package/build/storage/credential-store.js.map +1 -1
- package/build/storage/data-dir.d.ts +2 -2
- package/build/storage/data-dir.js +2 -2
- package/build/storage/encryption.d.ts.map +1 -1
- package/build/storage/encryption.js +4 -0
- package/build/storage/encryption.js.map +1 -1
- package/build/storage/index.d.ts +5 -3
- package/build/storage/index.d.ts.map +1 -1
- package/build/storage/index.js +5 -3
- package/build/storage/index.js.map +1 -1
- package/build/storage/key-pool-crypto.d.ts +44 -0
- package/build/storage/key-pool-crypto.d.ts.map +1 -0
- package/build/storage/key-pool-crypto.js +132 -0
- package/build/storage/key-pool-crypto.js.map +1 -0
- package/build/storage/key-pool-store.d.ts +134 -0
- package/build/storage/key-pool-store.d.ts.map +1 -0
- package/build/storage/key-pool-store.js +470 -0
- package/build/storage/key-pool-store.js.map +1 -0
- package/build/storage/key-pool.d.ts +172 -0
- package/build/storage/key-pool.d.ts.map +1 -0
- package/build/storage/key-pool.js +374 -0
- package/build/storage/key-pool.js.map +1 -0
- package/build/storage/resolver.js +1 -1
- package/build/storage/resolver.js.map +1 -1
- package/build/token-auth/index.d.ts +1 -1
- package/build/token-auth/index.js +1 -1
- package/build/transport/index.d.ts +1 -2
- package/build/transport/index.d.ts.map +1 -1
- package/build/transport/index.js +1 -2
- package/build/transport/index.js.map +1 -1
- package/build/transport/local-server.d.ts +12 -128
- package/build/transport/local-server.d.ts.map +1 -1
- package/build/transport/local-server.js +20 -262
- package/build/transport/local-server.js.map +1 -1
- package/build/transport/streamable-http.d.ts +6 -7
- package/build/transport/streamable-http.d.ts.map +1 -1
- package/build/transport/streamable-http.js +6 -6
- package/build/transport/streamable-http.js.map +1 -1
- package/build/util/browser.d.ts.map +1 -0
- package/build/{relay → util}/browser.js +2 -2
- package/build/util/browser.js.map +1 -0
- package/package.json +4 -21
- package/build/auth/credential-form.d.ts +0 -163
- package/build/auth/credential-form.d.ts.map +0 -1
- package/build/auth/credential-form.js +0 -1869
- package/build/auth/credential-form.js.map +0 -1
- package/build/auth/delegated-oauth-app.d.ts +0 -90
- package/build/auth/delegated-oauth-app.d.ts.map +0 -1
- package/build/auth/delegated-oauth-app.js +0 -881
- package/build/auth/delegated-oauth-app.js.map +0 -1
- package/build/auth/index.d.ts +0 -12
- package/build/auth/index.d.ts.map +0 -1
- package/build/auth/index.js +0 -12
- package/build/auth/index.js.map +0 -1
- package/build/auth/local-oauth-app.d.ts +0 -152
- package/build/auth/local-oauth-app.d.ts.map +0 -1
- package/build/auth/local-oauth-app.js +0 -975
- package/build/auth/local-oauth-app.js.map +0 -1
- package/build/auth/relay-login.d.ts +0 -46
- package/build/auth/relay-login.d.ts.map +0 -1
- package/build/auth/relay-login.js +0 -197
- package/build/auth/relay-login.js.map +0 -1
- package/build/auth/router.d.ts +0 -18
- package/build/auth/router.d.ts.map +0 -1
- package/build/auth/router.js +0 -62
- package/build/auth/router.js.map +0 -1
- package/build/auth/session-store.d.ts +0 -49
- package/build/auth/session-store.d.ts.map +0 -1
- package/build/auth/session-store.js +0 -94
- package/build/auth/session-store.js.map +0 -1
- package/build/auth/stable-sub.d.ts +0 -27
- package/build/auth/stable-sub.d.ts.map +0 -1
- package/build/auth/stable-sub.js +0 -38
- package/build/auth/stable-sub.js.map +0 -1
- package/build/auth/well-known.d.ts +0 -4
- package/build/auth/well-known.d.ts.map +0 -1
- package/build/auth/well-known.js +0 -26
- package/build/auth/well-known.js.map +0 -1
- package/build/oauth/cache.d.ts +0 -22
- package/build/oauth/cache.d.ts.map +0 -1
- package/build/oauth/cache.js +0 -24
- package/build/oauth/cache.js.map +0 -1
- package/build/oauth/index.d.ts +0 -6
- package/build/oauth/index.d.ts.map +0 -1
- package/build/oauth/index.js +0 -6
- package/build/oauth/index.js.map +0 -1
- package/build/oauth/jwt-issuer.d.ts +0 -87
- package/build/oauth/jwt-issuer.d.ts.map +0 -1
- package/build/oauth/jwt-issuer.js +0 -205
- package/build/oauth/jwt-issuer.js.map +0 -1
- package/build/oauth/pkce.d.ts +0 -10
- package/build/oauth/pkce.d.ts.map +0 -1
- package/build/oauth/pkce.js +0 -34
- package/build/oauth/pkce.js.map +0 -1
- package/build/oauth/provider.d.ts +0 -52
- package/build/oauth/provider.d.ts.map +0 -1
- package/build/oauth/provider.js +0 -98
- package/build/oauth/provider.js.map +0 -1
- package/build/oauth/user-store.d.ts +0 -25
- package/build/oauth/user-store.d.ts.map +0 -1
- package/build/oauth/user-store.js +0 -85
- package/build/oauth/user-store.js.map +0 -1
- package/build/relay/browser.d.ts.map +0 -1
- package/build/relay/browser.js.map +0 -1
- package/build/relay/client.d.ts +0 -36
- package/build/relay/client.d.ts.map +0 -1
- package/build/relay/client.js +0 -135
- package/build/relay/client.js.map +0 -1
- package/build/relay/session.d.ts +0 -12
- package/build/relay/session.d.ts.map +0 -1
- package/build/relay/session.js +0 -55
- package/build/relay/session.js.map +0 -1
- package/build/relay/tool-helpers.d.ts +0 -85
- package/build/relay/tool-helpers.d.ts.map +0 -1
- package/build/relay/tool-helpers.js +0 -103
- package/build/relay/tool-helpers.js.map +0 -1
- package/build/relay/wordlist.d.ts +0 -2
- package/build/relay/wordlist.d.ts.map +0 -1
- package/build/relay/wordlist.js +0 -7782
- package/build/relay/wordlist.js.map +0 -1
- package/build/schema/types.d.ts +0 -67
- package/build/schema/types.d.ts.map +0 -1
- package/build/schema/types.js +0 -2
- package/build/schema/types.js.map +0 -1
- package/build/storage/mode.d.ts +0 -23
- package/build/storage/mode.d.ts.map +0 -1
- package/build/storage/mode.js +0 -46
- package/build/storage/mode.js.map +0 -1
- package/build/storage/session-lock.d.ts +0 -19
- package/build/storage/session-lock.d.ts.map +0 -1
- package/build/storage/session-lock.js +0 -105
- package/build/storage/session-lock.js.map +0 -1
- package/build/transport/oauth-middleware.d.ts +0 -45
- package/build/transport/oauth-middleware.d.ts.map +0 -1
- package/build/transport/oauth-middleware.js +0 -86
- package/build/transport/oauth-middleware.js.map +0 -1
- /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, '<')
|
|
93
|
-
.replace(/>/g, '>')
|
|
94
|
-
.replace(/"/g, '"')
|
|
95
|
-
.replace(/'/g, ''');
|
|
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
|