mcp-auth-wrapper 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,200 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WrapperOAuthProvider = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const ACCESS_TOKEN_TTL_MS = 3_600_000; // 1 hour
6
+ const REFRESH_TOKEN_TTL_MS = 30 * 24 * 3_600_000; // 30 days — must be > ACCESS_TOKEN_TTL_MS, otherwise refresh is useless
7
+ const AUTH_CODE_TTL_MS = 300_000; // 5 minutes
8
+ const PENDING_AUTH_TTL_MS = 600_000; // 10 minutes — must be >= AUTH_CODE_TTL_MS, as pending auth spans upstream login + param collection
9
+ /** Encrypt + authenticate a JSON payload. Returns a URL-safe base64 string. */
10
+ const seal = (payload, key) => {
11
+ const iv = (0, node_crypto_1.randomBytes)(12);
12
+ const cipher = (0, node_crypto_1.createCipheriv)('aes-256-gcm', key, iv);
13
+ const plaintext = Buffer.from(JSON.stringify(payload));
14
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
15
+ const tag = cipher.getAuthTag();
16
+ return Buffer.concat([iv, tag, encrypted]).toString('base64url');
17
+ };
18
+ /** Decrypt + verify a sealed payload. Returns undefined if tampered or expired. */
19
+ const unseal = (sealed, key) => {
20
+ try {
21
+ const buf = Buffer.from(sealed, 'base64url');
22
+ const iv = buf.subarray(0, 12);
23
+ const tag = buf.subarray(12, 28);
24
+ const encrypted = buf.subarray(28);
25
+ const decipher = (0, node_crypto_1.createDecipheriv)('aes-256-gcm', key, iv);
26
+ decipher.setAuthTag(tag);
27
+ const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
28
+ const payload = JSON.parse(decrypted.toString());
29
+ if (payload.expiresAt < Date.now()) {
30
+ return undefined;
31
+ }
32
+ return payload;
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ };
38
+ class WrapperOAuthProvider {
39
+ oidcClient;
40
+ config;
41
+ clientsStore;
42
+ key;
43
+ constructor(oidcClient, config) {
44
+ this.oidcClient = oidcClient;
45
+ this.config = config;
46
+ this.key = config.secret
47
+ ? (0, node_crypto_1.createHash)('sha256').update(config.secret).digest()
48
+ : (0, node_crypto_1.randomBytes)(32);
49
+ this.clientsStore = {
50
+ // Always return a synthetic client — we handle /authorize ourselves
51
+ // and only need this for the SDK's /token client authentication.
52
+ getClient: (clientId) => ({
53
+ client_id: clientId,
54
+ redirect_uris: [],
55
+ token_endpoint_auth_method: 'none',
56
+ }),
57
+ registerClient: (metadata) => ({
58
+ ...metadata,
59
+ client_id: metadata.client_id || (0, node_crypto_1.randomUUID)(),
60
+ client_id_issued_at: Math.floor(Date.now() / 1000),
61
+ }),
62
+ };
63
+ }
64
+ async authorize(client, params, res) {
65
+ const { codeVerifier, codeChallenge } = this.oidcClient.generateCodeVerifierAndChallenge();
66
+ const payload = {
67
+ upstreamCodeVerifier: codeVerifier,
68
+ clientId: client.client_id,
69
+ redirectUri: params.redirectUri,
70
+ codeChallenge: params.codeChallenge,
71
+ scopes: params.scopes ?? [],
72
+ expiresAt: Date.now() + PENDING_AUTH_TTL_MS,
73
+ };
74
+ if (params.state) {
75
+ payload.state = params.state;
76
+ }
77
+ const sealedState = seal(payload, this.key);
78
+ const issuerUrl = this.config.issuerUrl ?? `http://localhost:${this.config.port ?? 3000}`;
79
+ const callbackUrl = `${issuerUrl}/callback`;
80
+ const url = await this.oidcClient.buildAuthorizeUrl({
81
+ redirectUri: callbackUrl,
82
+ state: sealedState,
83
+ codeChallenge,
84
+ });
85
+ res.redirect(url);
86
+ }
87
+ /** Unseal the state returned from the upstream callback. */
88
+ unsealState(sealedState) {
89
+ return unseal(sealedState, this.key);
90
+ }
91
+ /** Re-seal a modified payload (e.g. after adding userId). */
92
+ sealState(payload) {
93
+ return seal(payload, this.key);
94
+ }
95
+ completeAuthorization(pending, userId) {
96
+ const code = seal({
97
+ clientId: pending.clientId,
98
+ userId,
99
+ codeChallenge: pending.codeChallenge,
100
+ redirectUri: pending.redirectUri,
101
+ scopes: pending.scopes,
102
+ expiresAt: Date.now() + AUTH_CODE_TTL_MS,
103
+ }, this.key);
104
+ const redirectUrl = new URL(pending.redirectUri);
105
+ redirectUrl.searchParams.set('code', code);
106
+ if (pending.state) {
107
+ redirectUrl.searchParams.set('state', pending.state);
108
+ }
109
+ return { redirectUrl: redirectUrl.toString() };
110
+ }
111
+ async challengeForAuthorizationCode(_client, authorizationCode) {
112
+ const ac = unseal(authorizationCode, this.key);
113
+ if (!ac) {
114
+ throw new Error('Invalid authorization code');
115
+ }
116
+ return ac.codeChallenge;
117
+ }
118
+ // Note: we don't validate redirect_uri here. OAuth 2.1 requires it, but since we
119
+ // don't enforce client registration (any client_id + redirect_uri is accepted), there's
120
+ // nothing meaningful to check against. PKCE prevents code interception regardless.
121
+ async exchangeAuthorizationCode(client, authorizationCode) {
122
+ const ac = unseal(authorizationCode, this.key);
123
+ if (!ac) {
124
+ throw new Error('Invalid authorization code');
125
+ }
126
+ if (ac.clientId !== client.client_id) {
127
+ throw new Error('Authorization code was not issued to this client');
128
+ }
129
+ const accessToken = seal({
130
+ type: 'access',
131
+ clientId: client.client_id,
132
+ userId: ac.userId,
133
+ scopes: ac.scopes,
134
+ expiresAt: Date.now() + ACCESS_TOKEN_TTL_MS,
135
+ }, this.key);
136
+ const refreshToken = seal({
137
+ type: 'refresh',
138
+ clientId: client.client_id,
139
+ userId: ac.userId,
140
+ scopes: ac.scopes,
141
+ expiresAt: Date.now() + REFRESH_TOKEN_TTL_MS,
142
+ }, this.key);
143
+ return {
144
+ access_token: accessToken,
145
+ token_type: 'bearer',
146
+ expires_in: Math.floor(ACCESS_TOKEN_TTL_MS / 1000),
147
+ refresh_token: refreshToken,
148
+ scope: ac.scopes.join(' '),
149
+ };
150
+ }
151
+ async exchangeRefreshToken(client, refreshToken) {
152
+ const rt = unseal(refreshToken, this.key);
153
+ if (!rt || rt.type !== 'refresh') {
154
+ throw new Error('Invalid refresh token');
155
+ }
156
+ if (rt.clientId !== client.client_id) {
157
+ throw new Error('Refresh token was not issued to this client');
158
+ }
159
+ const newAccessToken = seal({
160
+ type: 'access',
161
+ clientId: client.client_id,
162
+ userId: rt.userId,
163
+ scopes: rt.scopes,
164
+ expiresAt: Date.now() + ACCESS_TOKEN_TTL_MS,
165
+ }, this.key);
166
+ const newRefreshToken = seal({
167
+ type: 'refresh',
168
+ clientId: client.client_id,
169
+ userId: rt.userId,
170
+ scopes: rt.scopes,
171
+ expiresAt: Date.now() + REFRESH_TOKEN_TTL_MS,
172
+ }, this.key);
173
+ return {
174
+ access_token: newAccessToken,
175
+ token_type: 'bearer',
176
+ expires_in: Math.floor(ACCESS_TOKEN_TTL_MS / 1000),
177
+ refresh_token: newRefreshToken,
178
+ scope: rt.scopes.join(' '),
179
+ };
180
+ }
181
+ async verifyAccessToken(token) {
182
+ const td = unseal(token, this.key);
183
+ if (!td || td.type !== 'access') {
184
+ throw new Error('Invalid or expired access token');
185
+ }
186
+ return {
187
+ token,
188
+ clientId: td.clientId,
189
+ scopes: td.scopes,
190
+ expiresAt: Math.floor(td.expiresAt / 1000),
191
+ extra: { userId: td.userId },
192
+ };
193
+ }
194
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- required by OAuthServerProvider interface
195
+ async revokeToken(client, request) {
196
+ // Tokens are stateless sealed blobs — revocation is a no-op.
197
+ // Tokens remain valid until their TTL expires.
198
+ }
199
+ }
200
+ exports.WrapperOAuthProvider = WrapperOAuthProvider;
@@ -0,0 +1,3 @@
1
+ import type { EnvParam } from './types.js';
2
+ export declare const renderParamsForm: (params: EnvParam[], sessionId: string, existingValues?: Record<string, string>) => string;
3
+ export declare const renderReconfigurePage: (params: EnvParam[], token: string, existingValues: Record<string, string>, saved?: boolean) => string;
package/dist/pages.js ADDED
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderReconfigurePage = exports.renderParamsForm = void 0;
4
+ const escapeHtml = (s) => s
5
+ .replace(/&/g, '&amp;')
6
+ .replace(/</g, '&lt;')
7
+ .replace(/>/g, '&gt;')
8
+ .replace(/"/g, '&quot;');
9
+ const renderParamsForm = (params, sessionId, existingValues) => `<!DOCTYPE html>
10
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
11
+ <title>Configure MCP Server</title>
12
+ <style>body{font-family:system-ui,sans-serif;max-width:480px;margin:40px auto;padding:0 20px}
13
+ label{display:block;margin:16px 0 4px;font-weight:600}
14
+ input{width:100%;padding:8px;box-sizing:border-box;border:1px solid #ccc;border-radius:4px}
15
+ button{margin-top:20px;padding:10px 24px;background:#0066cc;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:16px}
16
+ .desc{font-size:13px;color:#666;margin-top:2px}</style>
17
+ </head><body>
18
+ <h1>Configure MCP Server</h1>
19
+ <p>Enter your credentials to complete setup.</p>
20
+ <form method="POST">
21
+ <input type="hidden" name="session" value="${escapeHtml(sessionId)}">
22
+ ${params.map((p) => `<label for="${escapeHtml(p.name)}">${escapeHtml(p.label)}</label>
23
+ ${p.description ? `<div class="desc">${escapeHtml(p.description)}</div>` : ''}
24
+ <input id="${escapeHtml(p.name)}" name="${escapeHtml(p.name)}" type="${p.secret ? 'password' : 'text'}" value="${escapeHtml(existingValues?.[p.name] ?? '')}">`).join('\n')}
25
+ <button type="submit">Save &amp; Continue</button>
26
+ </form></body></html>`;
27
+ exports.renderParamsForm = renderParamsForm;
28
+ const renderReconfigurePage = (params, token, existingValues, saved) => `<!DOCTYPE html>
29
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
30
+ <title>Reconfigure MCP Server</title>
31
+ <style>body{font-family:system-ui,sans-serif;max-width:480px;margin:40px auto;padding:0 20px}
32
+ label{display:block;margin:16px 0 4px;font-weight:600}
33
+ input{width:100%;padding:8px;box-sizing:border-box;border:1px solid #ccc;border-radius:4px}
34
+ button{margin-top:20px;padding:10px 24px;background:#0066cc;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:16px}
35
+ .desc{font-size:13px;color:#666;margin-top:2px}
36
+ .success{background:#d4edda;border:1px solid #c3e6cb;padding:12px;border-radius:4px;margin-bottom:16px}</style>
37
+ </head><body>
38
+ <h1>Reconfigure MCP Server</h1>
39
+ ${saved ? '<div class="success">Settings saved. Your MCP server will use the new configuration on the next request.</div>' : ''}
40
+ <p>Update your credentials below.</p>
41
+ <form method="POST">
42
+ <input type="hidden" name="token" value="${escapeHtml(token)}">
43
+ ${params.map((p) => `<label for="${escapeHtml(p.name)}">${escapeHtml(p.label)}</label>
44
+ ${p.description ? `<div class="desc">${escapeHtml(p.description)}</div>` : ''}
45
+ <input id="${escapeHtml(p.name)}" name="${escapeHtml(p.name)}" type="${p.secret ? 'password' : 'text'}" value="${escapeHtml(existingValues[p.name] ?? '')}">`).join('\n')}
46
+ <button type="submit">Save</button>
47
+ </form></body></html>`;
48
+ exports.renderReconfigurePage = renderReconfigurePage;
@@ -0,0 +1,15 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import type { Store } from './store.js';
3
+ export declare class ProcessPool {
4
+ private readonly command;
5
+ private readonly args;
6
+ private readonly baseEnv;
7
+ private readonly store;
8
+ private readonly processes;
9
+ private readonly reapInterval;
10
+ constructor(command: string, args: string[], baseEnv: Record<string, string>, store: Store);
11
+ getClient(userId: string): Promise<Client>;
12
+ invalidateUser(userId: string): void;
13
+ shutdown(): Promise<void>;
14
+ private reapIdle;
15
+ }
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ProcessPool = void 0;
4
+ const index_js_1 = require("@modelcontextprotocol/sdk/client/index.js");
5
+ const stdio_js_1 = require("@modelcontextprotocol/sdk/client/stdio.js");
6
+ const IDLE_TIMEOUT_MS = 300_000;
7
+ const noop = () => { };
8
+ class ProcessPool {
9
+ command;
10
+ args;
11
+ baseEnv;
12
+ store;
13
+ processes = new Map();
14
+ reapInterval;
15
+ constructor(command, args, baseEnv, store) {
16
+ this.command = command;
17
+ this.args = args;
18
+ this.baseEnv = baseEnv;
19
+ this.store = store;
20
+ this.reapInterval = setInterval(() => {
21
+ this.reapIdle();
22
+ }, 60_000);
23
+ }
24
+ async getClient(userId) {
25
+ const existing = this.processes.get(userId);
26
+ if (existing) {
27
+ existing.lastUsed = Date.now();
28
+ return existing.client;
29
+ }
30
+ const userParams = this.store.getUser(userId);
31
+ if (!userParams) {
32
+ throw new Error(`Unknown user: ${userId}`);
33
+ }
34
+ const transport = new stdio_js_1.StdioClientTransport({
35
+ command: this.command,
36
+ args: this.args,
37
+ env: { ...process.env, ...this.baseEnv, ...userParams },
38
+ });
39
+ const client = new index_js_1.Client({ name: 'mcp-auth-wrapper', version: '1.0.0' });
40
+ await client.connect(transport);
41
+ this.processes.set(userId, { client, transport, lastUsed: Date.now() });
42
+ // Remove stale entry if the child process dies unexpectedly
43
+ transport.onclose = () => {
44
+ this.processes.delete(userId);
45
+ };
46
+ transport.onerror = () => {
47
+ this.processes.delete(userId);
48
+ };
49
+ return client;
50
+ }
51
+ invalidateUser(userId) {
52
+ const entry = this.processes.get(userId);
53
+ if (entry) {
54
+ entry.client.close().catch(noop);
55
+ this.processes.delete(userId);
56
+ }
57
+ }
58
+ async shutdown() {
59
+ if (this.reapInterval) {
60
+ clearInterval(this.reapInterval);
61
+ }
62
+ await Promise.all([...this.processes.values()].map(async (entry) => entry.client.close().catch(noop)));
63
+ this.processes.clear();
64
+ }
65
+ reapIdle() {
66
+ const now = Date.now();
67
+ for (const [userId, entry] of this.processes) {
68
+ if (now - entry.lastUsed > IDLE_TIMEOUT_MS) {
69
+ entry.client.close().catch(noop);
70
+ this.processes.delete(userId);
71
+ }
72
+ }
73
+ }
74
+ }
75
+ exports.ProcessPool = ProcessPool;
@@ -0,0 +1,44 @@
1
+ import type { EnvParam } from './types.js';
2
+ import type { Store } from './store.js';
3
+ import type { ProcessPool } from './process-pool.js';
4
+ export declare const RECONFIGURE_TOOL_NAME = "mcp_auth_wrapper__reconfigure";
5
+ export declare const getReconfigureTool: (reconfigureUrl: string, envParams: EnvParam[]) => {
6
+ name: string;
7
+ description: string;
8
+ inputSchema: {
9
+ type: "object";
10
+ properties: {
11
+ [k: string]: {
12
+ type: "string";
13
+ description: string;
14
+ };
15
+ };
16
+ };
17
+ };
18
+ export declare const handleReconfigureCall: (args: Record<string, unknown>, { store, pool, userId, envPerUser, reconfigureUrl }: {
19
+ store: Store;
20
+ pool: ProcessPool;
21
+ userId: string;
22
+ envPerUser: EnvParam[];
23
+ reconfigureUrl: string;
24
+ }) => {
25
+ isError: boolean;
26
+ content: {
27
+ type: "text";
28
+ text: string;
29
+ }[];
30
+ structuredContent: {
31
+ error: string;
32
+ validParameters: string[];
33
+ };
34
+ } | {
35
+ content: {
36
+ type: "text";
37
+ text: string;
38
+ }[];
39
+ structuredContent: {
40
+ status: string;
41
+ message: string;
42
+ };
43
+ isError?: never;
44
+ };
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handleReconfigureCall = exports.getReconfigureTool = exports.RECONFIGURE_TOOL_NAME = void 0;
4
+ exports.RECONFIGURE_TOOL_NAME = 'mcp_auth_wrapper__reconfigure';
5
+ const getReconfigureTool = (reconfigureUrl, envParams) => ({
6
+ name: exports.RECONFIGURE_TOOL_NAME,
7
+ description: 'Update your configuration for this MCP server. Call with parameter values to update directly, or call with no arguments to get a browser URL for configuration.',
8
+ inputSchema: {
9
+ type: 'object',
10
+ properties: Object.fromEntries(envParams.map((p) => [
11
+ p.name,
12
+ { type: 'string', description: p.description ?? p.label },
13
+ ])),
14
+ },
15
+ });
16
+ exports.getReconfigureTool = getReconfigureTool;
17
+ const handleReconfigureCall = (args, { store, pool, userId, envPerUser, reconfigureUrl }) => {
18
+ const knownNames = new Set(envPerUser.map((p) => p.name));
19
+ const unknownKeys = Object.keys(args).filter((k) => !knownNames.has(k));
20
+ if (unknownKeys.length > 0) {
21
+ const result = {
22
+ error: `Unknown parameter(s): ${unknownKeys.join(', ')}`,
23
+ validParameters: envPerUser.map((p) => p.name),
24
+ };
25
+ return {
26
+ isError: true,
27
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
28
+ structuredContent: result,
29
+ };
30
+ }
31
+ const hasValues = envPerUser.some((p) => typeof args[p.name] === 'string' && args[p.name] !== '');
32
+ if (hasValues) {
33
+ const params = {};
34
+ for (const p of envPerUser) {
35
+ const val = args[p.name];
36
+ if (typeof val === 'string' && val !== '') {
37
+ params[p.name] = val;
38
+ }
39
+ }
40
+ try {
41
+ store.upsertUser(userId, params);
42
+ pool.invalidateUser(userId);
43
+ const result = {
44
+ status: 'updated',
45
+ message: 'Configuration updated. Your MCP server will use the new settings on the next request.',
46
+ };
47
+ return {
48
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
49
+ structuredContent: result,
50
+ };
51
+ }
52
+ catch {
53
+ // Storage is read-only (inline config) — fall through to URL mode
54
+ }
55
+ }
56
+ const result = {
57
+ status: 'reconfigure',
58
+ url: reconfigureUrl,
59
+ message: 'To update your configuration, open this URL in your browser. After saving changes, your MCP server process will restart with the new settings.',
60
+ };
61
+ return {
62
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
63
+ structuredContent: result,
64
+ };
65
+ };
66
+ exports.handleReconfigureCall = handleReconfigureCall;
@@ -0,0 +1,7 @@
1
+ import express from 'express';
2
+ import type { WrapperOAuthProvider } from './oauth-provider.js';
3
+ import type { OidcClient } from './auth.js';
4
+ import type { Store } from './store.js';
5
+ import type { ProcessPool } from './process-pool.js';
6
+ import type { WrapperConfig } from './types.js';
7
+ export declare const createApp: (config: WrapperConfig, pool: ProcessPool, provider: WrapperOAuthProvider, oidcClient: OidcClient, store: Store) => express.Express;