mcp-google-multi 5.4.1-alpha.2 → 6.0.0-alpha.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 +21 -16
- package/dist/accounts.d.ts +44 -5
- package/dist/accounts.js +267 -25
- package/dist/auth.d.ts +11 -4
- package/dist/auth.js +85 -78
- package/dist/client-config.d.ts +90 -0
- package/dist/client-config.js +282 -0
- package/dist/client.js +10 -4
- package/dist/config-file.d.ts +38 -0
- package/dist/config-file.js +117 -0
- package/dist/discover.js +57 -3
- package/dist/doctor.d.ts +86 -0
- package/dist/doctor.js +384 -0
- package/dist/env-load.d.ts +11 -0
- package/dist/env-load.js +52 -0
- package/dist/executor.d.ts +2 -0
- package/dist/executor.js +2 -1
- package/dist/fanout.js +8 -5
- package/dist/fs-atomic.d.ts +3 -0
- package/dist/fs-atomic.js +123 -0
- package/dist/http-config.d.ts +35 -0
- package/dist/http-config.js +113 -0
- package/dist/http-transport.d.ts +59 -0
- package/dist/http-transport.js +269 -0
- package/dist/identity.d.ts +24 -0
- package/dist/identity.js +19 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +199 -18
- package/dist/master-key.d.ts +53 -0
- package/dist/master-key.js +242 -0
- package/dist/mcp-token.d.ts +81 -0
- package/dist/mcp-token.js +176 -0
- package/dist/migrate-config.d.ts +7 -0
- package/dist/migrate-config.js +103 -0
- package/dist/migrate-tokens.js +4 -3
- package/dist/net-tuning.d.ts +8 -0
- package/dist/net-tuning.js +25 -0
- package/dist/oauth-as.d.ts +52 -0
- package/dist/oauth-as.js +457 -0
- package/dist/oauth-consent.d.ts +28 -0
- package/dist/oauth-consent.js +109 -0
- package/dist/open-url.d.ts +1 -0
- package/dist/open-url.js +30 -0
- package/dist/reauth-hint.d.ts +2 -0
- package/dist/reauth-hint.js +15 -0
- package/dist/registry-transfer.d.ts +41 -0
- package/dist/registry-transfer.js +219 -0
- package/dist/registry.d.ts +27 -1
- package/dist/registry.js +117 -6
- package/dist/scope-catalog.d.ts +19 -0
- package/dist/scope-catalog.js +182 -0
- package/dist/scope-observability.d.ts +78 -0
- package/dist/scope-observability.js +192 -0
- package/dist/services.js +1 -1
- package/dist/setup-prompt.d.ts +9 -0
- package/dist/setup-prompt.js +59 -0
- package/dist/ssrf-guard.d.ts +48 -0
- package/dist/ssrf-guard.js +242 -0
- package/dist/token-store.js +23 -125
- package/dist/tools/_errors.d.ts +8 -2
- package/dist/tools/_errors.js +97 -6
- package/dist/tools/account-wizard.d.ts +33 -0
- package/dist/tools/account-wizard.js +284 -0
- package/dist/tools/accounts-tool.d.ts +8 -5
- package/dist/tools/accounts-tool.js +38 -15
- package/dist/tools/admin.js +2 -2
- package/dist/tools/calendar.js +2 -2
- package/dist/tools/chat.js +2 -2
- package/dist/tools/contacts.d.ts +40 -0
- package/dist/tools/contacts.js +135 -2
- package/dist/tools/docs.d.ts +3 -0
- package/dist/tools/docs.js +60 -9
- package/dist/tools/drive.d.ts +3 -0
- package/dist/tools/drive.js +60 -8
- package/dist/tools/forms.js +4 -2
- package/dist/tools/generated/_shared.d.ts +1 -3
- package/dist/tools/generated/_shared.js +3 -3
- package/dist/tools/generated/admin.js +175 -132
- package/dist/tools/generated/appsmarket.js +6 -2
- package/dist/tools/generated/calendar.js +45 -27
- package/dist/tools/generated/chat.js +81 -47
- package/dist/tools/generated/classroom.js +131 -104
- package/dist/tools/generated/cloudidentity.js +73 -61
- package/dist/tools/generated/cloudsearch.js +59 -49
- package/dist/tools/generated/contacts.js +22 -14
- package/dist/tools/generated/drive.js +51 -35
- package/dist/tools/generated/driveactivity.js +5 -1
- package/dist/tools/generated/drivelabels.js +30 -25
- package/dist/tools/generated/forms.js +7 -3
- package/dist/tools/generated/gmail.js +73 -58
- package/dist/tools/generated/groupsmigration.js +5 -1
- package/dist/tools/generated/groupssettings.js +7 -3
- package/dist/tools/generated/keep.js +12 -7
- package/dist/tools/generated/licensing.js +11 -7
- package/dist/tools/generated/meet.js +20 -13
- package/dist/tools/generated/postmaster.js +8 -4
- package/dist/tools/generated/reseller.js +22 -17
- package/dist/tools/generated/script.js +26 -16
- package/dist/tools/generated/sheets.js +11 -7
- package/dist/tools/generated/tasks.js +6 -2
- package/dist/tools/generated/vault.js +38 -33
- package/dist/tools/generated/workspaceevents.js +12 -7
- package/dist/tools/gmail-mime.d.ts +38 -8
- package/dist/tools/gmail-mime.js +75 -71
- package/dist/tools/gmail.d.ts +31 -0
- package/dist/tools/gmail.js +483 -99
- package/dist/tools/google-api.js +7 -4
- package/dist/tools/meet.js +2 -2
- package/dist/tools/searchconsole.js +2 -2
- package/dist/tools/sheets.js +6 -2
- package/dist/tools/slides.js +4 -2
- package/dist/tools/tasks.js +2 -2
- package/dist/types.d.ts +1 -1
- package/dist/write-control.d.ts +24 -1
- package/dist/write-control.js +26 -1
- package/package.json +26 -7
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
import type { AuthOutcome, RouteHandler } from './http-transport.js';
|
|
3
|
+
import { ReplayGuard, RefreshStore, type StatePayload } from './mcp-token.js';
|
|
4
|
+
export declare const DCR_MAX_CLIENTS = 1000;
|
|
5
|
+
export declare const DCR_MAX_REDIRECT_URIS = 10;
|
|
6
|
+
export interface GoogleExchangeResult {
|
|
7
|
+
tokens: Record<string, unknown>;
|
|
8
|
+
email?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface AuthServerConfig {
|
|
11
|
+
base: string;
|
|
12
|
+
resourceUri: string;
|
|
13
|
+
secret: Uint8Array;
|
|
14
|
+
ownerEmails: string[];
|
|
15
|
+
cimdIssuers: string[];
|
|
16
|
+
accessTtlSec?: number;
|
|
17
|
+
masterKey: string;
|
|
18
|
+
refreshStorePath: string;
|
|
19
|
+
}
|
|
20
|
+
export interface AuthServerDeps {
|
|
21
|
+
fetchCimd?: (clientId: string) => Promise<Record<string, unknown>>;
|
|
22
|
+
/** Exchange a Google auth code at `${base}/callback` for tokens (+ owner email). */
|
|
23
|
+
exchangeCode?: (code: string, flow: StatePayload['flow']) => Promise<GoogleExchangeResult>;
|
|
24
|
+
/** Build the Google authorization URL (redirect back to `${base}/callback`).
|
|
25
|
+
* The impl chooses scope/prompt/login_hint from the flow: owner_gate =
|
|
26
|
+
* `openid email` + select_account; alias_reauth = the alias's resource scopes
|
|
27
|
+
* + consent. */
|
|
28
|
+
buildGoogleAuthUrl?: (opts: {
|
|
29
|
+
flow: StatePayload['flow'];
|
|
30
|
+
alias?: string;
|
|
31
|
+
state: string;
|
|
32
|
+
}) => string;
|
|
33
|
+
writeToken?: (alias: string, tokens: Record<string, unknown>) => void;
|
|
34
|
+
registeredClients?: Map<string, {
|
|
35
|
+
redirect_uris: string[];
|
|
36
|
+
}>;
|
|
37
|
+
replayGuard?: ReplayGuard;
|
|
38
|
+
refreshStore?: RefreshStore;
|
|
39
|
+
now?: () => number;
|
|
40
|
+
/** Server-side log (never sent to callers) for auth failures / SSRF blocks. */
|
|
41
|
+
log?: (line: string) => void;
|
|
42
|
+
/** The alias's configured Google email, for the alias_reauth identity binding. */
|
|
43
|
+
aliasEmail?: (alias: string) => string | undefined;
|
|
44
|
+
}
|
|
45
|
+
export interface AuthServer {
|
|
46
|
+
routes: Record<string, RouteHandler>;
|
|
47
|
+
authenticate: (req: IncomingMessage) => Promise<AuthOutcome>;
|
|
48
|
+
}
|
|
49
|
+
/** C6: exact redirect_uri match, with RFC 8252 §7.3 port-agnostic loopback and
|
|
50
|
+
* the fixed claude.ai callback. */
|
|
51
|
+
export declare function redirectAllowed(redirectUri: string, docRedirectUris: string[]): boolean;
|
|
52
|
+
export declare function buildAuthServer(config: AuthServerConfig, deps?: AuthServerDeps): AuthServer;
|
package/dist/oauth-as.js
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
// B13: the minimal OAuth 2.1 Authorization Server + Resource Server
|
|
2
|
+
// (oauth-authorization-server.md). Federate-and-hold: the server mints its own
|
|
3
|
+
// audience-bound HS256 token for the MCP client (leg A), gates on an owner
|
|
4
|
+
// Google login (leg B, tokens discarded), and separately holds per-alias Google
|
|
5
|
+
// tokens (leg C, unchanged). Mounts into B12's HttpTransportHost as `routes` +
|
|
6
|
+
// an `authenticate` for POST /mcp. All Google/CIMD I/O is injectable so the
|
|
7
|
+
// whole surface is exercised by an offline harness.
|
|
8
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
9
|
+
import { isIPv4 } from 'node:net';
|
|
10
|
+
import { signAccessToken, verifyAccessToken, signState, verifyState, signAuthzCode, verifyAuthzCode, signPending, verifyPending, ReplayGuard, RefreshStore, STATE_TTL_DEFAULT, CODE_TTL_DEFAULT, ACCESS_TTL_DEFAULT, } from './mcp-token.js';
|
|
11
|
+
import { fetchCimdDocument, SsrfBlockedError } from './ssrf-guard.js';
|
|
12
|
+
const CLAUDE_AI_FIXED_CALLBACK = 'https://claude.ai/api/mcp/auth_callback';
|
|
13
|
+
// DCR (/register) is public + unauthenticated, so its in-memory store must be
|
|
14
|
+
// bounded like every sibling store (ReplayGuard cap, RefreshStore SPENT_CAP) or
|
|
15
|
+
// a request loop OOMs the process. Cap client count (FIFO-evict the oldest) and
|
|
16
|
+
// the per-request redirect_uris shape so one entry can't be arbitrarily large.
|
|
17
|
+
export const DCR_MAX_CLIENTS = 1000;
|
|
18
|
+
export const DCR_MAX_REDIRECT_URIS = 10;
|
|
19
|
+
const DCR_MAX_URI_LEN = 2048;
|
|
20
|
+
// --- helpers ----------------------------------------------------------------
|
|
21
|
+
function json(res, status, body, extraHeaders = {}) {
|
|
22
|
+
res.writeHead(status, { 'Content-Type': 'application/json', ...extraHeaders });
|
|
23
|
+
res.end(JSON.stringify(body));
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
function errorPage(res, status, slug, message) {
|
|
27
|
+
res.writeHead(status, { 'Content-Type': 'text/plain' });
|
|
28
|
+
res.end(`${slug}: ${message}`);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
function redirect(res, location) {
|
|
32
|
+
res.writeHead(302, { Location: location });
|
|
33
|
+
res.end();
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
function isLoopbackHostname(h) {
|
|
37
|
+
const host = h.replace(/^\[|\]$/g, '').toLowerCase();
|
|
38
|
+
if (host === 'localhost' || host === '::1' || host === '::ffff:127.0.0.1')
|
|
39
|
+
return true;
|
|
40
|
+
// 127.0.0.0/8 — must be a genuine IPv4 literal in range, NOT merely a "127."
|
|
41
|
+
// prefix (which would let "127.evil.com" masquerade as loopback).
|
|
42
|
+
return isIPv4(host) && host.split('.')[0] === '127';
|
|
43
|
+
}
|
|
44
|
+
/** C6: exact redirect_uri match, with RFC 8252 §7.3 port-agnostic loopback and
|
|
45
|
+
* the fixed claude.ai callback. */
|
|
46
|
+
export function redirectAllowed(redirectUri, docRedirectUris) {
|
|
47
|
+
if (redirectUri === CLAUDE_AI_FIXED_CALLBACK)
|
|
48
|
+
return true;
|
|
49
|
+
if (docRedirectUris.includes(redirectUri))
|
|
50
|
+
return true;
|
|
51
|
+
let r;
|
|
52
|
+
try {
|
|
53
|
+
r = new URL(redirectUri);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
if (!isLoopbackHostname(r.hostname))
|
|
59
|
+
return false; // non-loopback must be exact
|
|
60
|
+
return docRedirectUris.some((d) => {
|
|
61
|
+
let du;
|
|
62
|
+
try {
|
|
63
|
+
du = new URL(d);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
// any loopback host + any port + same scheme + same path
|
|
69
|
+
return isLoopbackHostname(du.hostname) && du.protocol === r.protocol && du.pathname === r.pathname;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
function pkceS256(verifier) {
|
|
73
|
+
return createHash('sha256').update(verifier).digest('base64url');
|
|
74
|
+
}
|
|
75
|
+
function readBody(req, maxBytes = 64 * 1024) {
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
let size = 0;
|
|
78
|
+
const chunks = [];
|
|
79
|
+
req.on('data', (c) => {
|
|
80
|
+
size += c.length;
|
|
81
|
+
if (size > maxBytes) {
|
|
82
|
+
reject(new Error('body too large'));
|
|
83
|
+
req.destroy();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
chunks.push(c);
|
|
87
|
+
});
|
|
88
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
89
|
+
req.on('error', reject);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
function parseForm(raw, contentType) {
|
|
93
|
+
if (contentType?.includes('application/json')) {
|
|
94
|
+
try {
|
|
95
|
+
const obj = JSON.parse(raw);
|
|
96
|
+
const out = {};
|
|
97
|
+
for (const [k, v] of Object.entries(obj))
|
|
98
|
+
out[k] = String(v);
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return {};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const out = {};
|
|
106
|
+
for (const [k, v] of new URLSearchParams(raw))
|
|
107
|
+
out[k] = v;
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
// --- the AS -----------------------------------------------------------------
|
|
111
|
+
export function buildAuthServer(config, deps = {}) {
|
|
112
|
+
const base = config.base;
|
|
113
|
+
const resource = config.resourceUri;
|
|
114
|
+
const secret = config.secret;
|
|
115
|
+
const accessTtl = config.accessTtlSec ?? ACCESS_TTL_DEFAULT;
|
|
116
|
+
const cimd = deps.fetchCimd ?? ((clientId) => fetchCimdDocument(clientId));
|
|
117
|
+
const replay = deps.replayGuard ?? new ReplayGuard();
|
|
118
|
+
const refresh = deps.refreshStore ?? new RefreshStore(config.refreshStorePath, config.masterKey);
|
|
119
|
+
const registered = deps.registeredClients ?? new Map();
|
|
120
|
+
const now = deps.now ?? (() => Date.now());
|
|
121
|
+
const log = deps.log ?? (() => undefined);
|
|
122
|
+
const nowSec = () => Math.floor(now() / 1000);
|
|
123
|
+
const issHeader = { 'Cache-Control': 'no-store' };
|
|
124
|
+
const prm = {
|
|
125
|
+
resource,
|
|
126
|
+
authorization_servers: [base],
|
|
127
|
+
scopes_supported: ['mcp:use'],
|
|
128
|
+
bearer_methods_supported: ['header'],
|
|
129
|
+
};
|
|
130
|
+
const asMetadata = {
|
|
131
|
+
issuer: base,
|
|
132
|
+
authorization_endpoint: `${base}/authorize`,
|
|
133
|
+
token_endpoint: `${base}/token`,
|
|
134
|
+
registration_endpoint: `${base}/register`,
|
|
135
|
+
response_types_supported: ['code'],
|
|
136
|
+
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
137
|
+
code_challenge_methods_supported: ['S256'],
|
|
138
|
+
token_endpoint_auth_methods_supported: ['none'],
|
|
139
|
+
client_id_metadata_document_supported: true,
|
|
140
|
+
authorization_response_iss_parameter_supported: true,
|
|
141
|
+
scopes_supported: ['mcp:use'],
|
|
142
|
+
};
|
|
143
|
+
const cimdCache = new Map();
|
|
144
|
+
const CIMD_CACHE_TTL_MS = 5 * 60_000;
|
|
145
|
+
async function validateClient(clientId) {
|
|
146
|
+
const reg = registered.get(clientId);
|
|
147
|
+
if (reg)
|
|
148
|
+
return { redirect_uris: reg.redirect_uris, dcr: true };
|
|
149
|
+
// C7/#5: validate the issuer host + scheme BEFORE any outbound fetch, so we
|
|
150
|
+
// only ever fetch an allowlisted issuer (no arbitrary-URL SSRF surface).
|
|
151
|
+
let issuerHost;
|
|
152
|
+
let scheme;
|
|
153
|
+
try {
|
|
154
|
+
const u = new URL(clientId);
|
|
155
|
+
issuerHost = u.hostname;
|
|
156
|
+
scheme = u.protocol;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return { error: 'E_CIMD_INVALID', message: 'invalid client' };
|
|
160
|
+
}
|
|
161
|
+
if (scheme !== 'https:' || !config.cimdIssuers.includes(issuerHost)) {
|
|
162
|
+
log(`CIMD rejected: client_id ${clientId} issuer not allowlisted`);
|
|
163
|
+
return { error: 'E_CIMD_INVALID', message: 'invalid client' };
|
|
164
|
+
}
|
|
165
|
+
let doc;
|
|
166
|
+
const cached = cimdCache.get(clientId);
|
|
167
|
+
if (cached && cached.exp > now()) {
|
|
168
|
+
doc = cached.doc;
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
try {
|
|
172
|
+
doc = await cimd(clientId);
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
// #13: never echo internal fetch details to the caller (blind-SSRF
|
|
176
|
+
// oracle); log server-side, return a generic message.
|
|
177
|
+
log(`CIMD fetch failed for ${clientId}: ${e.message}`);
|
|
178
|
+
const slug = e instanceof SsrfBlockedError ? 'E_CIMD_SSRF_BLOCKED' : 'E_CIMD_INVALID';
|
|
179
|
+
return { error: slug, message: 'invalid client' };
|
|
180
|
+
}
|
|
181
|
+
cimdCache.set(clientId, { doc, exp: now() + CIMD_CACHE_TTL_MS }); // #15
|
|
182
|
+
}
|
|
183
|
+
if (doc.client_id !== clientId)
|
|
184
|
+
return { error: 'E_CIMD_INVALID', message: 'invalid client' };
|
|
185
|
+
const uris = Array.isArray(doc.redirect_uris) ? doc.redirect_uris : [];
|
|
186
|
+
if (uris.length === 0)
|
|
187
|
+
return { error: 'E_CIMD_INVALID', message: 'invalid client' };
|
|
188
|
+
// C6 #14: warn on a localhost-only redirect set (loopback impersonation).
|
|
189
|
+
if (uris.every((u) => { try {
|
|
190
|
+
return isLoopbackHostname(new URL(u).hostname);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return false;
|
|
194
|
+
} })) {
|
|
195
|
+
log(`client ${clientId} advertises only loopback redirect_uris`);
|
|
196
|
+
}
|
|
197
|
+
return { redirect_uris: uris, dcr: false };
|
|
198
|
+
}
|
|
199
|
+
function escapeHtml(s) {
|
|
200
|
+
return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
201
|
+
}
|
|
202
|
+
async function toGoogle(res, sp) {
|
|
203
|
+
const state = await signState(sp, base, secret, nowSec());
|
|
204
|
+
const authUrl = deps.buildGoogleAuthUrl
|
|
205
|
+
? deps.buildGoogleAuthUrl({ flow: sp.flow, alias: sp.alias, state })
|
|
206
|
+
: `https://accounts.google.com/o/oauth2/v2/auth?state=${encodeURIComponent(state)}`;
|
|
207
|
+
return redirect(res, authUrl);
|
|
208
|
+
}
|
|
209
|
+
// GET /authorize (validate + interstitial for DCR clients); POST /authorize
|
|
210
|
+
// (a DCR consent approval — verify the signed pending token, then proceed).
|
|
211
|
+
const authorize = async (req, res, url) => {
|
|
212
|
+
if (req.method === 'POST') {
|
|
213
|
+
let form;
|
|
214
|
+
try {
|
|
215
|
+
form = parseForm(await readBody(req), req.headers['content-type']);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return errorPage(res, 400, 'invalid_request', 'bad form');
|
|
219
|
+
}
|
|
220
|
+
let pending;
|
|
221
|
+
try {
|
|
222
|
+
pending = await verifyPending(form.pending ?? '', base, secret);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return errorPage(res, 400, 'E_STATE_INVALID', 'the approval is invalid or expired');
|
|
226
|
+
}
|
|
227
|
+
if (!replay.consume(pending.jti, STATE_TTL_DEFAULT * 1000, now())) {
|
|
228
|
+
return errorPage(res, 400, 'E_STATE_INVALID', 'this approval was already used');
|
|
229
|
+
}
|
|
230
|
+
const { jti: _drop, ...sp } = pending;
|
|
231
|
+
void _drop;
|
|
232
|
+
return toGoogle(res, sp);
|
|
233
|
+
}
|
|
234
|
+
const q = url.searchParams;
|
|
235
|
+
const clientId = q.get('client_id') ?? '';
|
|
236
|
+
const redirectUri = q.get('redirect_uri') ?? '';
|
|
237
|
+
const codeChallenge = q.get('code_challenge') ?? '';
|
|
238
|
+
const method = q.get('code_challenge_method') ?? '';
|
|
239
|
+
const resourceParam = q.get('resource') ?? '';
|
|
240
|
+
const clientState = q.get('state') ?? undefined;
|
|
241
|
+
const aliasParam = q.get('alias') ?? undefined;
|
|
242
|
+
// In-call re-auth (BV-1): a user-clicked `flow=alias_reauth` link has NO
|
|
243
|
+
// client leg — no code is delivered to any client, the refreshed token is
|
|
244
|
+
// written server-side for the alias — so it skips the client_id/redirect/
|
|
245
|
+
// PKCE requirements. Safe because /callback binds the completing Google
|
|
246
|
+
// identity to the alias's configured email before writing anything.
|
|
247
|
+
if (q.get('flow') === 'alias_reauth' && aliasParam && !clientId) {
|
|
248
|
+
if (!deps.aliasEmail?.(aliasParam)) {
|
|
249
|
+
return errorPage(res, 400, 'invalid_request', `unknown account "${aliasParam}"`);
|
|
250
|
+
}
|
|
251
|
+
return toGoogle(res, { flow: 'alias_reauth', client_id: '', redirect_uri: '', code_challenge: '', resource, alias: aliasParam });
|
|
252
|
+
}
|
|
253
|
+
if (!clientId)
|
|
254
|
+
return json(res, 400, { error: 'invalid_request', message: 'client_id required', iss: base }, issHeader);
|
|
255
|
+
const client = await validateClient(clientId);
|
|
256
|
+
if ('error' in client)
|
|
257
|
+
return json(res, 400, { error: 'invalid_client', message: client.message, iss: base }, issHeader);
|
|
258
|
+
// (2) exact redirect_uri match BEFORE minting anything (open-redirect defense)
|
|
259
|
+
if (!redirectUri || !redirectAllowed(redirectUri, client.redirect_uris)) {
|
|
260
|
+
return errorPage(res, 400, 'E_REDIRECT_URI_MISMATCH', 'redirect_uri is not an allowed callback for this client');
|
|
261
|
+
}
|
|
262
|
+
// (3) PKCE S256 required
|
|
263
|
+
if (!codeChallenge || method !== 'S256') {
|
|
264
|
+
return json(res, 400, { error: 'invalid_request', message: 'code_challenge with S256 required', iss: base }, issHeader);
|
|
265
|
+
}
|
|
266
|
+
// (4) resource must be the canonical resource URI
|
|
267
|
+
if (resourceParam !== resource) {
|
|
268
|
+
return json(res, 400, { error: 'invalid_request', message: `resource must be ${resource}`, iss: base }, issHeader);
|
|
269
|
+
}
|
|
270
|
+
// (5) only after full validation do we build the request artifact.
|
|
271
|
+
const statePayload = {
|
|
272
|
+
flow: q.get('flow') === 'alias_reauth' ? 'alias_reauth' : 'owner_gate',
|
|
273
|
+
client_id: clientId,
|
|
274
|
+
redirect_uri: redirectUri,
|
|
275
|
+
code_challenge: codeChallenge,
|
|
276
|
+
client_state: clientState,
|
|
277
|
+
resource,
|
|
278
|
+
alias: q.get('alias') ?? undefined,
|
|
279
|
+
};
|
|
280
|
+
// Confused-deputy defense (#3): a self-registered DCR client can pick an
|
|
281
|
+
// arbitrary redirect_uri, so before we send the owner to Google (whose
|
|
282
|
+
// consent screen names only THIS server, not the requesting app), show an
|
|
283
|
+
// approval interstitial naming the client + destination. CIMD clients skip
|
|
284
|
+
// it — their redirect_uris come from an issuer-allowlisted document a rogue
|
|
285
|
+
// cannot forge, so there is nothing to spoof.
|
|
286
|
+
if (client.dcr) {
|
|
287
|
+
const pendingToken = await signPending(statePayload, base, secret, nowSec());
|
|
288
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
289
|
+
res.end(`<!doctype html><meta charset=utf-8><title>Authorize access</title>` +
|
|
290
|
+
`<body style="font-family:system-ui,sans-serif;max-width:36em;margin:3em auto;line-height:1.5">` +
|
|
291
|
+
`<h2>Authorize access?</h2>` +
|
|
292
|
+
`<p>A newly-registered application (<code>${escapeHtml(clientId)}</code>) is requesting access to your Google Workspace through this server.</p>` +
|
|
293
|
+
`<p>If approved, it will receive an authorization redirected to:<br><code>${escapeHtml(redirectUri)}</code></p>` +
|
|
294
|
+
`<p><strong>If you did not start this, close this window.</strong></p>` +
|
|
295
|
+
`<form method="POST" action="/authorize"><input type="hidden" name="pending" value="${escapeHtml(pendingToken)}">` +
|
|
296
|
+
`<button type="submit" style="font-size:1em;padding:.5em 1.5em">Approve</button></form></body>`);
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
return toGoogle(res, statePayload);
|
|
300
|
+
};
|
|
301
|
+
// GET /callback
|
|
302
|
+
const callback = async (req, res, url) => {
|
|
303
|
+
const code = url.searchParams.get('code') ?? '';
|
|
304
|
+
const stateToken = url.searchParams.get('state') ?? '';
|
|
305
|
+
let st;
|
|
306
|
+
try {
|
|
307
|
+
st = await verifyState(stateToken, base, secret);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return errorPage(res, 400, 'E_STATE_INVALID', 'state is invalid, expired, or tampered');
|
|
311
|
+
}
|
|
312
|
+
if (!replay.consume(st.jti, STATE_TTL_DEFAULT * 1000, now())) {
|
|
313
|
+
return errorPage(res, 400, 'E_STATE_INVALID', 'state has already been used (replay)');
|
|
314
|
+
}
|
|
315
|
+
if (!code)
|
|
316
|
+
return errorPage(res, 400, 'invalid_request', 'missing code');
|
|
317
|
+
let exchanged;
|
|
318
|
+
try {
|
|
319
|
+
exchanged = deps.exchangeCode ? await deps.exchangeCode(code, st.flow) : { tokens: {} };
|
|
320
|
+
}
|
|
321
|
+
catch (e) {
|
|
322
|
+
return errorPage(res, 400, 'invalid_grant', `Google code exchange failed: ${e.message}`);
|
|
323
|
+
}
|
|
324
|
+
if (st.flow === 'alias_reauth') {
|
|
325
|
+
if (!st.alias)
|
|
326
|
+
return errorPage(res, 400, 'invalid_request', 'alias_reauth without an alias');
|
|
327
|
+
// C13 / identity binding (#2/#6): only overwrite the alias's tokens if the
|
|
328
|
+
// completing Google identity IS that alias's configured account — else an
|
|
329
|
+
// attacker who crafts an alias_reauth link and logs in with their OWN
|
|
330
|
+
// account could inject their tokens (account swap). The alias emails (leg
|
|
331
|
+
// C) are distinct from MCP_OWNER_EMAILS (leg B), so bind to the alias.
|
|
332
|
+
const gotEmail = (exchanged.email ?? '').toLowerCase();
|
|
333
|
+
const expected = (deps.aliasEmail?.(st.alias) ?? '').toLowerCase();
|
|
334
|
+
if (!expected || !gotEmail || gotEmail !== expected) {
|
|
335
|
+
log(`alias_reauth identity mismatch for "${st.alias}" (got ${gotEmail || 'none'})`);
|
|
336
|
+
return errorPage(res, 403, 'access_denied', 'the Google account you signed in with is not the one configured for this alias');
|
|
337
|
+
}
|
|
338
|
+
deps.writeToken?.(st.alias, exchanged.tokens);
|
|
339
|
+
log(`callback ok flow=alias_reauth alias=${st.alias}`);
|
|
340
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
341
|
+
res.end(`<!doctype html><meta charset=utf-8><p>Re-authenticated "${st.alias}". You can close this window.</p>`);
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
// owner_gate: verify the email is an allowlisted owner, discard Google tokens
|
|
345
|
+
const email = (exchanged.email ?? '').toLowerCase();
|
|
346
|
+
const iss = encodeURIComponent(base);
|
|
347
|
+
if (!email || !config.ownerEmails.includes(email)) {
|
|
348
|
+
const sep = st.redirect_uri.includes('?') ? '&' : '?';
|
|
349
|
+
const s = st.client_state ? `&state=${encodeURIComponent(st.client_state)}` : '';
|
|
350
|
+
return redirect(res, `${st.redirect_uri}${sep}error=access_denied${s}&iss=${iss}`);
|
|
351
|
+
}
|
|
352
|
+
const authzCode = await signAuthzCode({ redirect_uri: st.redirect_uri, code_challenge: st.code_challenge, resource: st.resource, sub: 'owner' }, base, secret, nowSec());
|
|
353
|
+
log('callback ok flow=owner_gate');
|
|
354
|
+
const sep = st.redirect_uri.includes('?') ? '&' : '?';
|
|
355
|
+
const s = st.client_state ? `&state=${encodeURIComponent(st.client_state)}` : '';
|
|
356
|
+
return redirect(res, `${st.redirect_uri}${sep}code=${encodeURIComponent(authzCode)}${s}&iss=${iss}`);
|
|
357
|
+
};
|
|
358
|
+
// POST /token
|
|
359
|
+
const token = async (req, res) => {
|
|
360
|
+
let form;
|
|
361
|
+
try {
|
|
362
|
+
form = parseForm(await readBody(req), req.headers['content-type']);
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
return json(res, 400, { error: 'invalid_request', message: 'bad body' });
|
|
366
|
+
}
|
|
367
|
+
const grant = form.grant_type;
|
|
368
|
+
if (grant === 'authorization_code') {
|
|
369
|
+
let code;
|
|
370
|
+
try {
|
|
371
|
+
code = await verifyAuthzCode(form.code ?? '', base, secret);
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
return json(res, 400, { error: 'invalid_grant', message: 'authorization code invalid or expired' });
|
|
375
|
+
}
|
|
376
|
+
if (!replay.consume(code.jti, CODE_TTL_DEFAULT * 1000, now())) {
|
|
377
|
+
return json(res, 400, { error: 'invalid_grant', message: 'authorization code already redeemed' });
|
|
378
|
+
}
|
|
379
|
+
if (form.redirect_uri !== code.redirect_uri) {
|
|
380
|
+
return json(res, 400, { error: 'invalid_grant', message: 'redirect_uri mismatch' });
|
|
381
|
+
}
|
|
382
|
+
if (form.resource && form.resource !== code.resource) {
|
|
383
|
+
return json(res, 400, { error: 'invalid_grant', message: 'resource mismatch' });
|
|
384
|
+
}
|
|
385
|
+
if (!form.code_verifier || pkceS256(form.code_verifier) !== code.code_challenge) {
|
|
386
|
+
return json(res, 400, { error: 'invalid_grant', message: 'PKCE verification failed' });
|
|
387
|
+
}
|
|
388
|
+
const access = await signAccessToken({ base, secret, iat: nowSec(), ttlSec: accessTtl });
|
|
389
|
+
const refreshToken = refresh.issue(now());
|
|
390
|
+
log('token issued grant=authorization_code');
|
|
391
|
+
return json(res, 200, { access_token: access, token_type: 'Bearer', expires_in: accessTtl, refresh_token: refreshToken, scope: 'mcp:use' });
|
|
392
|
+
}
|
|
393
|
+
if (grant === 'refresh_token') {
|
|
394
|
+
const next = refresh.rotate(form.refresh_token ?? '', now());
|
|
395
|
+
if (!next)
|
|
396
|
+
return json(res, 400, { error: 'invalid_grant', message: 'unknown or rotated refresh token' });
|
|
397
|
+
const access = await signAccessToken({ base, secret, iat: nowSec(), ttlSec: accessTtl });
|
|
398
|
+
log('token issued grant=refresh_token');
|
|
399
|
+
return json(res, 200, { access_token: access, token_type: 'Bearer', expires_in: accessTtl, refresh_token: next, scope: 'mcp:use' });
|
|
400
|
+
}
|
|
401
|
+
return json(res, 400, { error: 'unsupported_grant_type', message: 'authorization_code or refresh_token only' });
|
|
402
|
+
};
|
|
403
|
+
// POST /register (minimal DCR, D2). Parse the raw JSON body directly (the
|
|
404
|
+
// redirect_uris array must survive intact).
|
|
405
|
+
const register = async (req, res) => {
|
|
406
|
+
let parsed;
|
|
407
|
+
try {
|
|
408
|
+
parsed = JSON.parse(await readBody(req));
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
return json(res, 400, { error: 'invalid_client_metadata', message: 'a JSON body with redirect_uris is required' });
|
|
412
|
+
}
|
|
413
|
+
const redirectUris = Array.isArray(parsed.redirect_uris) ? parsed.redirect_uris.map(String) : [];
|
|
414
|
+
if (redirectUris.length === 0)
|
|
415
|
+
return json(res, 400, { error: 'invalid_client_metadata', message: 'redirect_uris required' });
|
|
416
|
+
if (redirectUris.length > DCR_MAX_REDIRECT_URIS || redirectUris.some((u) => u.length > DCR_MAX_URI_LEN)) {
|
|
417
|
+
return json(res, 400, { error: 'invalid_client_metadata', message: 'too many or oversized redirect_uris' });
|
|
418
|
+
}
|
|
419
|
+
// Bound the store: FIFO-evict the oldest registration once at capacity (Map
|
|
420
|
+
// preserves insertion order) so a /register flood can't grow RSS unbounded.
|
|
421
|
+
while (registered.size >= DCR_MAX_CLIENTS) {
|
|
422
|
+
const oldest = registered.keys().next().value;
|
|
423
|
+
if (oldest === undefined)
|
|
424
|
+
break;
|
|
425
|
+
registered.delete(oldest);
|
|
426
|
+
}
|
|
427
|
+
const clientId = `mcpb-${randomBytes(16).toString('hex')}`;
|
|
428
|
+
registered.set(clientId, { redirect_uris: redirectUris });
|
|
429
|
+
return json(res, 201, { client_id: clientId, redirect_uris: redirectUris, token_endpoint_auth_method: 'none' });
|
|
430
|
+
};
|
|
431
|
+
const wellKnownPrm = (req, res) => json(res, 200, prm);
|
|
432
|
+
const wellKnownAs = (req, res) => json(res, 200, asMetadata);
|
|
433
|
+
const routes = {
|
|
434
|
+
'/.well-known/oauth-protected-resource': wellKnownPrm,
|
|
435
|
+
'/.well-known/oauth-protected-resource/mcp': wellKnownPrm,
|
|
436
|
+
'/.well-known/oauth-authorization-server': wellKnownAs,
|
|
437
|
+
'/authorize': authorize,
|
|
438
|
+
'/callback': callback,
|
|
439
|
+
'/token': token,
|
|
440
|
+
'/register': register,
|
|
441
|
+
};
|
|
442
|
+
const wwwAuth = `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource", scope="mcp:use"`;
|
|
443
|
+
const authenticate = async (req) => {
|
|
444
|
+
const header = req.headers.authorization;
|
|
445
|
+
if (!header || !header.startsWith('Bearer ')) {
|
|
446
|
+
return { ok: false, status: 401, headers: { 'WWW-Authenticate': wwwAuth }, body: JSON.stringify({ error: 'E_MCP_TOKEN_INVALID', message: 'missing bearer token' }) };
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
await verifyAccessToken(header.slice(7), base, secret);
|
|
450
|
+
return { ok: true };
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
return { ok: false, status: 401, headers: { 'WWW-Authenticate': wwwAuth }, body: JSON.stringify({ error: 'E_MCP_TOKEN_INVALID', message: 'invalid or expired token' }) };
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
return { routes, authenticate };
|
|
457
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { OAuth2Client } from 'googleapis-common';
|
|
2
|
+
export declare const LOOPBACK_PORT = 4242;
|
|
3
|
+
export declare const LOOPBACK_REDIRECT = "http://localhost:4242/oauth2callback";
|
|
4
|
+
/** GOOGLE_CLIENT_ID/SECRET absent — caller maps to E_CLIENT_CREDENTIALS_MISSING. */
|
|
5
|
+
export declare class ClientCredentialsMissingError extends Error {
|
|
6
|
+
constructor();
|
|
7
|
+
}
|
|
8
|
+
export declare class LoopbackPortInUseError extends Error {
|
|
9
|
+
constructor();
|
|
10
|
+
}
|
|
11
|
+
export declare class ConsentTimeoutError extends Error {
|
|
12
|
+
constructor();
|
|
13
|
+
}
|
|
14
|
+
export declare class ConsentDeniedError extends Error {
|
|
15
|
+
constructor(reason: string);
|
|
16
|
+
}
|
|
17
|
+
export declare function hasClientCredentials(): boolean;
|
|
18
|
+
/** Build the loopback OAuth2 client from env credentials (throws if unset). */
|
|
19
|
+
export declare function buildConsentClient(): OAuth2Client;
|
|
20
|
+
/**
|
|
21
|
+
* Start the loopback server, await the OAuth redirect, validate the CSRF
|
|
22
|
+
* `state` (RFC 6749 §10.12), and exchange the code. Returns the token set for
|
|
23
|
+
* the caller to persist. Never process.exit's (safe inside a live server) and
|
|
24
|
+
* times out so a stalled consent can't wedge a tool call.
|
|
25
|
+
*/
|
|
26
|
+
export declare function awaitLoopbackConsent(client: OAuth2Client, expectedState: string, opts?: {
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
}): Promise<Record<string, unknown>>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { OAuth2Client } from 'googleapis-common';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import { URL } from 'node:url';
|
|
4
|
+
// Shared loopback OAuth consent (leg C of cc-auth), used by the account wizard
|
|
5
|
+
// (B7). The `auth --account` CLI keeps its own inline copy for now; this module
|
|
6
|
+
// is the reusable, live-server-safe form (throws typed errors instead of
|
|
7
|
+
// process.exit, and times out so a never-completed consent can't wedge a tool
|
|
8
|
+
// call forever). The redirect matches the CLI exactly so an existing registered
|
|
9
|
+
// Desktop client keeps working.
|
|
10
|
+
export const LOOPBACK_PORT = 4242;
|
|
11
|
+
export const LOOPBACK_REDIRECT = `http://localhost:${LOOPBACK_PORT}/oauth2callback`;
|
|
12
|
+
/** GOOGLE_CLIENT_ID/SECRET absent — caller maps to E_CLIENT_CREDENTIALS_MISSING. */
|
|
13
|
+
export class ClientCredentialsMissingError extends Error {
|
|
14
|
+
constructor() {
|
|
15
|
+
super('E_CLIENT_CREDENTIALS_MISSING: GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are not set.');
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export class LoopbackPortInUseError extends Error {
|
|
19
|
+
constructor() {
|
|
20
|
+
super(`E_LOOPBACK_PORT_IN_USE: port ${LOOPBACK_PORT} is already in use; close the other process and retry.`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export class ConsentTimeoutError extends Error {
|
|
24
|
+
constructor() {
|
|
25
|
+
super('E_CONSENT_TIMEOUT: no OAuth redirect arrived before the timeout.');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export class ConsentDeniedError extends Error {
|
|
29
|
+
constructor(reason) {
|
|
30
|
+
super(`E_CONSENT_DENIED: ${reason}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function hasClientCredentials() {
|
|
34
|
+
return Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
|
35
|
+
}
|
|
36
|
+
/** Build the loopback OAuth2 client from env credentials (throws if unset). */
|
|
37
|
+
export function buildConsentClient() {
|
|
38
|
+
if (!hasClientCredentials())
|
|
39
|
+
throw new ClientCredentialsMissingError();
|
|
40
|
+
return new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, LOOPBACK_REDIRECT);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Start the loopback server, await the OAuth redirect, validate the CSRF
|
|
44
|
+
* `state` (RFC 6749 §10.12), and exchange the code. Returns the token set for
|
|
45
|
+
* the caller to persist. Never process.exit's (safe inside a live server) and
|
|
46
|
+
* times out so a stalled consent can't wedge a tool call.
|
|
47
|
+
*/
|
|
48
|
+
export function awaitLoopbackConsent(client, expectedState, opts = {}) {
|
|
49
|
+
const timeoutMs = opts.timeoutMs ?? 5 * 60_000;
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
let timer;
|
|
52
|
+
const server = http.createServer(async (req, res) => {
|
|
53
|
+
if (!req.url || !req.url.startsWith('/oauth2callback')) {
|
|
54
|
+
res.writeHead(404).end();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const done = (code, body) => {
|
|
58
|
+
res.writeHead(code, { 'Content-Type': 'text/html' });
|
|
59
|
+
res.end(body);
|
|
60
|
+
if (timer)
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
server.close();
|
|
63
|
+
server.closeAllConnections();
|
|
64
|
+
};
|
|
65
|
+
try {
|
|
66
|
+
const qs = new URL(req.url, LOOPBACK_REDIRECT).searchParams;
|
|
67
|
+
const error = qs.get('error');
|
|
68
|
+
if (error) {
|
|
69
|
+
done(400, `<p>Authorization denied: ${error}</p>`);
|
|
70
|
+
reject(new ConsentDeniedError(error));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const returnedState = qs.get('state');
|
|
74
|
+
if (returnedState !== expectedState) {
|
|
75
|
+
done(400, '<p>State mismatch — possible CSRF attempt. Aborting.</p>');
|
|
76
|
+
reject(new Error('E_OAUTH_STATE_MISMATCH: OAuth state token mismatch'));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const code = qs.get('code');
|
|
80
|
+
if (!code) {
|
|
81
|
+
done(400, '<p>No authorization code received.</p>');
|
|
82
|
+
reject(new ConsentDeniedError('no authorization code received'));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const { tokens } = await client.getToken(code);
|
|
86
|
+
done(200, '<h2>Authentication successful!</h2><p>You can close this tab.</p>');
|
|
87
|
+
resolve(tokens);
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
done(500, '<p>Internal error during authentication.</p>');
|
|
91
|
+
reject(e);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
server.on('error', (err) => {
|
|
95
|
+
if (timer)
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
reject(err.code === 'EADDRINUSE' ? new LoopbackPortInUseError() : err);
|
|
98
|
+
});
|
|
99
|
+
server.listen(LOOPBACK_PORT, '127.0.0.1', () => {
|
|
100
|
+
timer = setTimeout(() => {
|
|
101
|
+
server.close();
|
|
102
|
+
server.closeAllConnections();
|
|
103
|
+
reject(new ConsentTimeoutError());
|
|
104
|
+
}, timeoutMs);
|
|
105
|
+
// unref so a pending consent never keeps the process alive on its own.
|
|
106
|
+
timer.unref?.();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function openUrl(url: string): void;
|