mcp-local-tunnel 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.
package/dist/agent.js ADDED
@@ -0,0 +1,184 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.startAgent = void 0;
7
+ const node_os_1 = __importDefault(require("node:os"));
8
+ const ws_1 = __importDefault(require("ws"));
9
+ const index_js_1 = require("@modelcontextprotocol/sdk/client/index.js");
10
+ const stdio_js_1 = require("@modelcontextprotocol/sdk/client/stdio.js");
11
+ const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/client/streamableHttp.js");
12
+ const agent_auth_js_1 = require("./agent-auth.js");
13
+ const MAX_RECONNECT_DELAY_MS = 30_000;
14
+ const INITIAL_RECONNECT_DELAY_MS = 1_000;
15
+ const toWsUrl = (relay) => {
16
+ if (relay.startsWith('ws://') || relay.startsWith('wss://')) {
17
+ return relay.endsWith('/ws') ? relay : `${relay.replace(/\/$/, '')}/ws`;
18
+ }
19
+ if (relay.startsWith('http://') || relay.startsWith('https://')) {
20
+ const url = new URL(relay);
21
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
22
+ url.pathname = `${url.pathname.replace(/\/$/, '')}/ws`;
23
+ return url.toString();
24
+ }
25
+ return `wss://${relay}/ws`;
26
+ };
27
+ const startAgent = async (config) => {
28
+ const name = config.name ?? node_os_1.default.hostname();
29
+ const relayUrl = toWsUrl(config.relay);
30
+ let reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
31
+ let shouldReconnect = true;
32
+ const spawnServers = async () => {
33
+ const servers = [];
34
+ const entries = Object.entries(config.servers);
35
+ // Spawn servers sequentially to avoid overwhelming the system
36
+ for (const [serverName, entry] of entries) {
37
+ try {
38
+ const client = new index_js_1.Client({ name: `agent-${serverName}`, version: '1.0.0' });
39
+ let transport;
40
+ if (entry.command) {
41
+ const [cmd, ...args] = entry.command;
42
+ transport = new stdio_js_1.StdioClientTransport({
43
+ command: cmd,
44
+ args,
45
+ env: { ...process.env, ...entry.env },
46
+ });
47
+ }
48
+ else if (entry.url) {
49
+ transport = new streamableHttp_js_1.StreamableHTTPClientTransport(new URL(entry.url));
50
+ }
51
+ else {
52
+ throw new Error(`Server "${serverName}" must have either "command" or "url"`);
53
+ }
54
+ // eslint-disable-next-line no-await-in-loop
55
+ await client.connect(transport);
56
+ servers.push({ name: serverName, client, transport });
57
+ console.log(` Started local server: ${serverName}`);
58
+ }
59
+ catch (err) {
60
+ console.error(` Failed to start server "${serverName}":`, err instanceof Error ? err.message : err);
61
+ }
62
+ }
63
+ return servers;
64
+ };
65
+ const collectTools = async (servers) => {
66
+ const tools = [];
67
+ for (const server of servers) {
68
+ try {
69
+ // eslint-disable-next-line no-await-in-loop
70
+ const result = await server.client.listTools();
71
+ for (const tool of result.tools) {
72
+ tools.push({
73
+ name: `${server.name}__${tool.name}`,
74
+ description: `[${server.name}] ${tool.description ?? ''}`,
75
+ inputSchema: tool.inputSchema,
76
+ ...(tool.annotations ? { annotations: tool.annotations } : {}),
77
+ });
78
+ }
79
+ }
80
+ catch (err) {
81
+ console.error(` Failed to list tools from "${server.name}":`, err instanceof Error ? err.message : err);
82
+ }
83
+ }
84
+ return tools;
85
+ };
86
+ const closeServers = async (servers) => {
87
+ for (const server of servers) {
88
+ try {
89
+ // eslint-disable-next-line no-await-in-loop
90
+ await server.client.close();
91
+ }
92
+ catch {
93
+ // Ignore cleanup errors
94
+ }
95
+ }
96
+ };
97
+ const handleToolCall = async (servers, toolName, args) => {
98
+ const separatorIndex = toolName.indexOf('__');
99
+ if (separatorIndex === -1) {
100
+ throw new Error(`Unknown tool: ${toolName}`);
101
+ }
102
+ const serverName = toolName.slice(0, separatorIndex);
103
+ const originalName = toolName.slice(separatorIndex + 2);
104
+ const server = servers.find((s) => s.name === serverName);
105
+ if (!server) {
106
+ throw new Error(`Unknown server: ${serverName}`);
107
+ }
108
+ const result = await server.client.callTool({ name: originalName, arguments: args });
109
+ return result;
110
+ };
111
+ const connect = async () => {
112
+ let servers = await spawnServers();
113
+ let tools = await collectTools(servers);
114
+ const token = await (0, agent_auth_js_1.getAccessToken)(config.relay);
115
+ const wsUrl = new URL(relayUrl);
116
+ wsUrl.searchParams.set('name', name);
117
+ wsUrl.searchParams.set('token', token);
118
+ console.log(`Connecting to ${config.relay} as "${name}"...`);
119
+ const ws = new ws_1.default(wsUrl.toString());
120
+ ws.on('open', () => {
121
+ reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
122
+ console.log(`Connected to ${config.relay} as "${name}"`);
123
+ console.log(`Serving ${tools.length} tools: ${tools.map((t) => t.name).join(', ')}`);
124
+ const msg = { type: 'tools', tools };
125
+ ws.send(JSON.stringify(msg));
126
+ });
127
+ ws.on('message', async (data) => {
128
+ try {
129
+ const msg = JSON.parse(data.toString());
130
+ if (msg.type === 'request') {
131
+ try {
132
+ const result = await handleToolCall(servers, msg.method, msg.params);
133
+ const response = { type: 'response', id: msg.id, result };
134
+ ws.send(JSON.stringify(response));
135
+ }
136
+ catch (err) {
137
+ const errMsg = {
138
+ type: 'error',
139
+ id: msg.id,
140
+ message: err instanceof Error ? err.message : String(err),
141
+ };
142
+ ws.send(JSON.stringify(errMsg));
143
+ }
144
+ }
145
+ else if (msg.type === 'restart') {
146
+ console.log('Restart requested by relay, restarting servers...');
147
+ await closeServers(servers);
148
+ servers = await spawnServers();
149
+ tools = await collectTools(servers);
150
+ const toolsMsg = { type: 'tools', tools };
151
+ ws.send(JSON.stringify(toolsMsg));
152
+ console.log(`Restarted. Now serving ${tools.length} tools.`);
153
+ }
154
+ }
155
+ catch (err) {
156
+ console.error('Error handling message:', err);
157
+ }
158
+ });
159
+ ws.on('close', () => {
160
+ console.log('Disconnected from relay.');
161
+ void closeServers(servers);
162
+ if (shouldReconnect) {
163
+ console.log(`Reconnecting in ${reconnectDelay / 1000}s...`);
164
+ setTimeout(() => {
165
+ void connect();
166
+ }, reconnectDelay);
167
+ reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY_MS);
168
+ }
169
+ });
170
+ ws.on('error', (err) => {
171
+ console.error('WebSocket error:', err.message);
172
+ });
173
+ const shutdown = () => {
174
+ shouldReconnect = false;
175
+ console.log('\nShutting down agent...');
176
+ ws.close();
177
+ void closeServers(servers).then(() => process.exit(0));
178
+ };
179
+ process.on('SIGINT', shutdown);
180
+ process.on('SIGTERM', shutdown);
181
+ };
182
+ await connect();
183
+ };
184
+ exports.startAgent = startAgent;
@@ -0,0 +1,2 @@
1
+ import type { Config } from './types.js';
2
+ export declare const loadConfig: (input?: string) => Config;
package/dist/config.js ADDED
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.loadConfig = void 0;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const types_js_1 = require("./types.js");
9
+ const DEFAULT_CONFIG_PATH = 'mcp-local-tunnel.config.json';
10
+ const loadConfig = (input) => {
11
+ const raw = input ?? process.env.MCP_LOCAL_TUNNEL_CONFIG;
12
+ let json;
13
+ if (!raw) {
14
+ if (node_fs_1.default.existsSync(DEFAULT_CONFIG_PATH)) {
15
+ json = JSON.parse(node_fs_1.default.readFileSync(DEFAULT_CONFIG_PATH, 'utf8'));
16
+ }
17
+ else {
18
+ throw new Error('No config found. Set MCP_LOCAL_TUNNEL_CONFIG or create mcp-local-tunnel.config.json');
19
+ }
20
+ }
21
+ else if (!raw.startsWith('{') && node_fs_1.default.existsSync(raw)) {
22
+ json = JSON.parse(node_fs_1.default.readFileSync(raw, 'utf8'));
23
+ }
24
+ else {
25
+ json = JSON.parse(raw);
26
+ }
27
+ const result = types_js_1.ConfigSchema.safeParse(json);
28
+ if (!result.success) {
29
+ const issues = result.error.issues.map((i) => ` ${i.path.join('.')}: ${i.message}`).join('\n');
30
+ throw new Error(`Invalid config:\n${issues}`);
31
+ }
32
+ return result.data;
33
+ };
34
+ exports.loadConfig = loadConfig;
@@ -0,0 +1,23 @@
1
+ import type WebSocket from 'ws';
2
+ import type { ToolDef } from './types.js';
3
+ export type AgentConnection = {
4
+ ws: WebSocket;
5
+ userId: string;
6
+ name: string;
7
+ connectedAt: number;
8
+ tools: ToolDef[];
9
+ };
10
+ export declare class ConnectionManager {
11
+ /** userId -> AgentConnection[] (one per device) */
12
+ private readonly connections;
13
+ private readonly pending;
14
+ addConnection(ws: WebSocket, userId: string, name: string): AgentConnection;
15
+ getConnectionsForUser(userId: string): AgentConnection[];
16
+ getToolsForUser(userId: string): (ToolDef & {
17
+ deviceName: string;
18
+ })[];
19
+ sendRequest(userId: string, deviceName: string, method: string, params: unknown): Promise<unknown>;
20
+ sendRestart(userId: string): void;
21
+ private removeConnection;
22
+ private handleMessage;
23
+ }
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ConnectionManager = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const REQUEST_TIMEOUT_MS = 120_000; // 2 minutes
6
+ class ConnectionManager {
7
+ /** userId -> AgentConnection[] (one per device) */
8
+ connections = new Map();
9
+ pending = new Map();
10
+ addConnection(ws, userId, name) {
11
+ const conn = {
12
+ ws, userId, name, connectedAt: Date.now(), tools: [],
13
+ };
14
+ ws.on('message', (data) => {
15
+ try {
16
+ const msg = JSON.parse(data.toString());
17
+ this.handleMessage(conn, msg);
18
+ }
19
+ catch (err) {
20
+ console.error('Failed to parse WebSocket message:', err);
21
+ }
22
+ });
23
+ ws.on('close', () => {
24
+ this.removeConnection(conn);
25
+ });
26
+ ws.on('error', (err) => {
27
+ console.error(`WebSocket error for ${name} (${userId}):`, err.message);
28
+ });
29
+ const existing = this.connections.get(userId) ?? [];
30
+ // Replace existing connection with same name
31
+ const filtered = existing.filter((c) => c.name !== name);
32
+ filtered.push(conn);
33
+ this.connections.set(userId, filtered);
34
+ console.log(`Agent connected: "${name}" (user: ${userId})`);
35
+ return conn;
36
+ }
37
+ getConnectionsForUser(userId) {
38
+ return this.connections.get(userId) ?? [];
39
+ }
40
+ getToolsForUser(userId) {
41
+ const conns = this.getConnectionsForUser(userId);
42
+ const tools = [];
43
+ const multiDevice = conns.length > 1;
44
+ for (const conn of conns) {
45
+ for (const tool of conn.tools) {
46
+ const name = multiDevice
47
+ ? `${conn.name}__${tool.name}`
48
+ : tool.name;
49
+ const description = multiDevice
50
+ ? `[${conn.name}] ${tool.description ?? ''}`
51
+ : (tool.description ?? '');
52
+ tools.push({
53
+ ...tool, name, description, deviceName: conn.name,
54
+ });
55
+ }
56
+ }
57
+ return tools;
58
+ }
59
+ async sendRequest(userId, deviceName, method, params) {
60
+ const conns = this.getConnectionsForUser(userId);
61
+ const conn = conns.find((c) => c.name === deviceName);
62
+ if (!conn) {
63
+ throw new Error(`No agent connected for device "${deviceName}"`);
64
+ }
65
+ const id = (0, node_crypto_1.randomUUID)();
66
+ const msg = {
67
+ type: 'request', id, method, params,
68
+ };
69
+ return new Promise((resolve, reject) => {
70
+ const timer = setTimeout(() => {
71
+ this.pending.delete(id);
72
+ reject(new Error('Request timed out'));
73
+ }, REQUEST_TIMEOUT_MS);
74
+ this.pending.set(id, { resolve, reject, timer });
75
+ conn.ws.send(JSON.stringify(msg));
76
+ });
77
+ }
78
+ sendRestart(userId) {
79
+ const conns = this.getConnectionsForUser(userId);
80
+ const msg = { type: 'restart' };
81
+ for (const conn of conns) {
82
+ conn.ws.send(JSON.stringify(msg));
83
+ }
84
+ }
85
+ removeConnection(conn) {
86
+ const existing = this.connections.get(conn.userId);
87
+ if (!existing) {
88
+ return;
89
+ }
90
+ const filtered = existing.filter((c) => c !== conn);
91
+ if (filtered.length === 0) {
92
+ this.connections.delete(conn.userId);
93
+ }
94
+ else {
95
+ this.connections.set(conn.userId, filtered);
96
+ }
97
+ console.log(`Agent disconnected: "${conn.name}" (user: ${conn.userId})`);
98
+ }
99
+ handleMessage(conn, msg) {
100
+ if (msg.type === 'tools') {
101
+ conn.tools = msg.tools;
102
+ console.log(`Agent "${conn.name}" registered ${msg.tools.length} tools`);
103
+ return;
104
+ }
105
+ if (msg.type === 'response' || msg.type === 'error') {
106
+ const req = this.pending.get(msg.id);
107
+ if (!req) {
108
+ return;
109
+ }
110
+ this.pending.delete(msg.id);
111
+ clearTimeout(req.timer);
112
+ if (msg.type === 'response') {
113
+ req.resolve(msg.result);
114
+ }
115
+ else {
116
+ req.reject(new Error(msg.message));
117
+ }
118
+ }
119
+ }
120
+ }
121
+ exports.ConnectionManager = ConnectionManager;
@@ -0,0 +1,9 @@
1
+ /** Derive a 256-bit AES key from a secret string, or generate a random one. */
2
+ export declare const deriveKey: (secret?: string) => Buffer;
3
+ /** Encrypt + authenticate a JSON payload with AES-256-GCM. Returns a URL-safe base64 string. */
4
+ export declare const seal: <T>(payload: T, key: Buffer) => string;
5
+ /** Decrypt + verify a sealed payload. Returns undefined if tampered, expired, or wrong type. */
6
+ export declare const unseal: <T extends {
7
+ type: string;
8
+ expiresAt: number;
9
+ }>(sealed: string, key: Buffer, expectedType: T["type"]) => T | undefined;
package/dist/crypto.js ADDED
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.unseal = exports.seal = exports.deriveKey = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ /** Derive a 256-bit AES key from a secret string, or generate a random one. */
6
+ const deriveKey = (secret) => secret
7
+ ? (0, node_crypto_1.createHash)('sha256').update(secret).digest()
8
+ : (0, node_crypto_1.randomBytes)(32);
9
+ exports.deriveKey = deriveKey;
10
+ /** Encrypt + authenticate a JSON payload with AES-256-GCM. Returns a URL-safe base64 string. */
11
+ const seal = (payload, key) => {
12
+ const iv = (0, node_crypto_1.randomBytes)(12);
13
+ const cipher = (0, node_crypto_1.createCipheriv)('aes-256-gcm', key, iv);
14
+ const plaintext = Buffer.from(JSON.stringify(payload));
15
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
16
+ const tag = cipher.getAuthTag();
17
+ return Buffer.concat([iv, tag, encrypted]).toString('base64url');
18
+ };
19
+ exports.seal = seal;
20
+ /** Decrypt + verify a sealed payload. Returns undefined if tampered, expired, or wrong type. */
21
+ const unseal = (sealed, key, expectedType) => {
22
+ try {
23
+ const buf = Buffer.from(sealed, 'base64url');
24
+ const iv = buf.subarray(0, 12);
25
+ const tag = buf.subarray(12, 28);
26
+ const encrypted = buf.subarray(28);
27
+ const decipher = (0, node_crypto_1.createDecipheriv)('aes-256-gcm', key, iv);
28
+ decipher.setAuthTag(tag);
29
+ const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
30
+ const payload = JSON.parse(decrypted.toString());
31
+ if (payload.type !== expectedType || payload.expiresAt < Date.now()) {
32
+ return undefined;
33
+ }
34
+ return payload;
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ };
40
+ exports.unseal = unseal;
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const config_js_1 = require("./config.js");
5
+ const relay_js_1 = require("./relay.js");
6
+ const agent_js_1 = require("./agent.js");
7
+ const main = async () => {
8
+ const config = (0, config_js_1.loadConfig)();
9
+ if (config.mode === 'relay') {
10
+ (0, relay_js_1.startRelay)(config);
11
+ }
12
+ else {
13
+ await (0, agent_js_1.startAgent)(config);
14
+ }
15
+ };
16
+ main().catch((err) => {
17
+ console.error(err);
18
+ process.exit(1);
19
+ });
@@ -0,0 +1,35 @@
1
+ import type { Response } from 'express';
2
+ import type { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
3
+ import type { AuthorizationParams, OAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/provider.js';
4
+ import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js';
5
+ import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
6
+ import type { OidcClient } from './oidc-client.js';
7
+ import type { RelayConfig } from './types.js';
8
+ type PendingAuthPayload = {
9
+ type: 'pending';
10
+ upstreamCodeVerifier: string;
11
+ clientId: string;
12
+ redirectUri: string;
13
+ codeChallenge: string;
14
+ state?: string;
15
+ scopes: string[];
16
+ expiresAt: number;
17
+ };
18
+ export declare class TunnelOAuthProvider implements OAuthServerProvider {
19
+ private readonly oidcClient;
20
+ private readonly config;
21
+ readonly clientsStore: OAuthRegisteredClientsStore;
22
+ private readonly key;
23
+ constructor(oidcClient: OidcClient, config: RelayConfig);
24
+ authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void>;
25
+ unsealState(sealedState: string): PendingAuthPayload | undefined;
26
+ completeAuthorization(pending: PendingAuthPayload, userId: string): {
27
+ redirectUrl: string;
28
+ };
29
+ challengeForAuthorizationCode(_client: OAuthClientInformationFull, authorizationCode: string): Promise<string>;
30
+ exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<OAuthTokens>;
31
+ exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string): Promise<OAuthTokens>;
32
+ verifyAccessToken(token: string): Promise<AuthInfo>;
33
+ revokeToken(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise<void>;
34
+ }
35
+ export {};
@@ -0,0 +1,162 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TunnelOAuthProvider = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const errors_js_1 = require("@modelcontextprotocol/sdk/server/auth/errors.js");
6
+ const crypto_js_1 = require("./crypto.js");
7
+ const ACCESS_TOKEN_TTL_MS = 3_600_000; // 1 hour
8
+ const REFRESH_TOKEN_TTL_MS = 30 * 24 * 3_600_000; // 30 days
9
+ const AUTH_CODE_TTL_MS = 300_000; // 5 minutes
10
+ const PENDING_AUTH_TTL_MS = 600_000; // 10 minutes
11
+ class TunnelOAuthProvider {
12
+ oidcClient;
13
+ config;
14
+ clientsStore;
15
+ key;
16
+ constructor(oidcClient, config) {
17
+ this.oidcClient = oidcClient;
18
+ this.config = config;
19
+ this.key = (0, crypto_js_1.deriveKey)(config.secret);
20
+ this.clientsStore = {
21
+ getClient: (clientId) => ({
22
+ client_id: clientId,
23
+ redirect_uris: [],
24
+ token_endpoint_auth_method: 'none',
25
+ }),
26
+ registerClient: (metadata) => ({
27
+ ...metadata,
28
+ client_id: metadata.client_id || (0, node_crypto_1.randomUUID)(),
29
+ client_id_issued_at: Math.floor(Date.now() / 1000),
30
+ }),
31
+ };
32
+ }
33
+ async authorize(client, params, res) {
34
+ const { codeVerifier, codeChallenge } = this.oidcClient.generateCodeVerifierAndChallenge();
35
+ const payload = {
36
+ type: 'pending',
37
+ upstreamCodeVerifier: codeVerifier,
38
+ clientId: client.client_id,
39
+ redirectUri: params.redirectUri,
40
+ codeChallenge: params.codeChallenge,
41
+ scopes: params.scopes ?? [],
42
+ expiresAt: Date.now() + PENDING_AUTH_TTL_MS,
43
+ };
44
+ if (params.state) {
45
+ payload.state = params.state;
46
+ }
47
+ const sealedState = (0, crypto_js_1.seal)(payload, this.key);
48
+ const issuerUrl = this.config.issuerUrl ?? `http://localhost:${this.config.port ?? 3000}`;
49
+ const callbackUrl = `${issuerUrl}/callback`;
50
+ const url = await this.oidcClient.buildAuthorizeUrl({
51
+ redirectUri: callbackUrl,
52
+ state: sealedState,
53
+ codeChallenge,
54
+ });
55
+ res.redirect(url);
56
+ }
57
+ unsealState(sealedState) {
58
+ return (0, crypto_js_1.unseal)(sealedState, this.key, 'pending');
59
+ }
60
+ completeAuthorization(pending, userId) {
61
+ const code = (0, crypto_js_1.seal)({
62
+ type: 'auth_code',
63
+ clientId: pending.clientId,
64
+ userId,
65
+ codeChallenge: pending.codeChallenge,
66
+ redirectUri: pending.redirectUri,
67
+ scopes: pending.scopes,
68
+ expiresAt: Date.now() + AUTH_CODE_TTL_MS,
69
+ }, this.key);
70
+ const redirectUrl = new URL(pending.redirectUri);
71
+ redirectUrl.searchParams.set('code', code);
72
+ if (pending.state) {
73
+ redirectUrl.searchParams.set('state', pending.state);
74
+ }
75
+ return { redirectUrl: redirectUrl.toString() };
76
+ }
77
+ async challengeForAuthorizationCode(_client, authorizationCode) {
78
+ const ac = (0, crypto_js_1.unseal)(authorizationCode, this.key, 'auth_code');
79
+ if (!ac) {
80
+ throw new Error('Invalid authorization code');
81
+ }
82
+ return ac.codeChallenge;
83
+ }
84
+ async exchangeAuthorizationCode(client, authorizationCode) {
85
+ const ac = (0, crypto_js_1.unseal)(authorizationCode, this.key, 'auth_code');
86
+ if (!ac) {
87
+ throw new Error('Invalid authorization code');
88
+ }
89
+ if (ac.clientId !== client.client_id) {
90
+ throw new Error('Authorization code was not issued to this client');
91
+ }
92
+ const accessToken = (0, crypto_js_1.seal)({
93
+ type: 'access',
94
+ clientId: client.client_id,
95
+ userId: ac.userId,
96
+ scopes: ac.scopes,
97
+ expiresAt: Date.now() + ACCESS_TOKEN_TTL_MS,
98
+ }, this.key);
99
+ const refreshToken = (0, crypto_js_1.seal)({
100
+ type: 'refresh',
101
+ clientId: client.client_id,
102
+ userId: ac.userId,
103
+ scopes: ac.scopes,
104
+ expiresAt: Date.now() + REFRESH_TOKEN_TTL_MS,
105
+ }, this.key);
106
+ return {
107
+ access_token: accessToken,
108
+ token_type: 'bearer',
109
+ expires_in: Math.floor(ACCESS_TOKEN_TTL_MS / 1000),
110
+ refresh_token: refreshToken,
111
+ scope: ac.scopes.join(' '),
112
+ };
113
+ }
114
+ async exchangeRefreshToken(client, refreshToken) {
115
+ const rt = (0, crypto_js_1.unseal)(refreshToken, this.key, 'refresh');
116
+ if (!rt) {
117
+ throw new Error('Invalid refresh token');
118
+ }
119
+ if (rt.clientId !== client.client_id) {
120
+ throw new Error('Refresh token was not issued to this client');
121
+ }
122
+ const newAccessToken = (0, crypto_js_1.seal)({
123
+ type: 'access',
124
+ clientId: client.client_id,
125
+ userId: rt.userId,
126
+ scopes: rt.scopes,
127
+ expiresAt: Date.now() + ACCESS_TOKEN_TTL_MS,
128
+ }, this.key);
129
+ const newRefreshToken = (0, crypto_js_1.seal)({
130
+ type: 'refresh',
131
+ clientId: client.client_id,
132
+ userId: rt.userId,
133
+ scopes: rt.scopes,
134
+ expiresAt: Date.now() + REFRESH_TOKEN_TTL_MS,
135
+ }, this.key);
136
+ return {
137
+ access_token: newAccessToken,
138
+ token_type: 'bearer',
139
+ expires_in: Math.floor(ACCESS_TOKEN_TTL_MS / 1000),
140
+ refresh_token: newRefreshToken,
141
+ scope: rt.scopes.join(' '),
142
+ };
143
+ }
144
+ async verifyAccessToken(token) {
145
+ const td = (0, crypto_js_1.unseal)(token, this.key, 'access');
146
+ if (!td) {
147
+ throw new errors_js_1.InvalidTokenError('Invalid or expired access token');
148
+ }
149
+ return {
150
+ token,
151
+ clientId: td.clientId,
152
+ scopes: td.scopes,
153
+ expiresAt: Math.floor(td.expiresAt / 1000),
154
+ extra: { userId: td.userId },
155
+ };
156
+ }
157
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- required by OAuthServerProvider interface
158
+ async revokeToken(client, request) {
159
+ // Tokens are stateless sealed blobs — revocation is a no-op.
160
+ }
161
+ }
162
+ exports.TunnelOAuthProvider = TunnelOAuthProvider;
@@ -0,0 +1,29 @@
1
+ import type { AuthConfig } from './types.js';
2
+ type OidcDiscovery = {
3
+ authorization_endpoint: string;
4
+ token_endpoint: string;
5
+ jwks_uri: string;
6
+ issuer: string;
7
+ };
8
+ export declare class OidcClient {
9
+ private readonly config;
10
+ private discovery;
11
+ private discoveryExpiresAt;
12
+ private jwks;
13
+ constructor(config: AuthConfig);
14
+ getDiscovery(): Promise<OidcDiscovery>;
15
+ buildAuthorizeUrl(params: {
16
+ redirectUri: string;
17
+ state: string;
18
+ codeChallenge: string;
19
+ }): Promise<string>;
20
+ generateCodeVerifierAndChallenge(): {
21
+ codeVerifier: string;
22
+ codeChallenge: string;
23
+ };
24
+ exchangeCode(code: string, redirectUri: string, codeVerifier: string): Promise<{
25
+ claims: Record<string, unknown>;
26
+ userId: string;
27
+ }>;
28
+ }
29
+ export {};