mcp-google-multi 6.0.0-beta.2 → 6.0.0-beta.3

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.
Files changed (165) hide show
  1. package/dist/accounts.d.ts +17 -1
  2. package/dist/accounts.js +52 -15
  3. package/dist/api-probe.d.ts +5 -0
  4. package/dist/api-probe.js +20 -15
  5. package/dist/arg-normalize.js +6 -5
  6. package/dist/arg-strict.d.ts +7 -0
  7. package/dist/arg-strict.js +34 -4
  8. package/dist/auth.d.ts +4 -3
  9. package/dist/auth.js +11 -13
  10. package/dist/boot-gates.d.ts +26 -0
  11. package/dist/boot-gates.js +38 -0
  12. package/dist/cimd-cache.d.ts +38 -0
  13. package/dist/cimd-cache.js +117 -0
  14. package/dist/client.d.ts +32 -2
  15. package/dist/client.js +0 -0
  16. package/dist/compose.d.ts +11 -0
  17. package/dist/compose.js +109 -0
  18. package/dist/config-file.d.ts +13 -0
  19. package/dist/config-file.js +37 -1
  20. package/dist/discover.js +3 -1
  21. package/dist/discovery-client.d.ts +3 -0
  22. package/dist/discovery-client.js +25 -2
  23. package/dist/doctor.d.ts +6 -2
  24. package/dist/doctor.js +119 -23
  25. package/dist/executor.d.ts +3 -0
  26. package/dist/executor.js +4 -2
  27. package/dist/fanout.d.ts +9 -1
  28. package/dist/fanout.js +44 -10
  29. package/dist/fs-atomic.d.ts +11 -0
  30. package/dist/fs-atomic.js +219 -28
  31. package/dist/hint-provider.d.ts +129 -0
  32. package/dist/hint-provider.js +64 -0
  33. package/dist/http-transport.d.ts +62 -4
  34. package/dist/http-transport.js +343 -24
  35. package/dist/identity.d.ts +4 -1
  36. package/dist/identity.js +25 -6
  37. package/dist/index.js +66 -138
  38. package/dist/master-key.js +13 -5
  39. package/dist/mcp-token.d.ts +136 -19
  40. package/dist/mcp-token.js +569 -72
  41. package/dist/migrate-config.js +2 -2
  42. package/dist/oauth-as.d.ts +103 -3
  43. package/dist/oauth-as.js +374 -79
  44. package/dist/oauth-consent.d.ts +10 -0
  45. package/dist/oauth-consent.js +16 -0
  46. package/dist/outbound-allowlist.js +5 -2
  47. package/dist/reauth-hint.d.ts +1 -1
  48. package/dist/reauth-hint.js +12 -8
  49. package/dist/registry-transfer.d.ts +5 -0
  50. package/dist/registry-transfer.js +65 -5
  51. package/dist/registry.d.ts +18 -1
  52. package/dist/registry.js +71 -15
  53. package/dist/scope-catalog.d.ts +11 -0
  54. package/dist/scope-catalog.js +23 -6
  55. package/dist/scope-observability.d.ts +8 -6
  56. package/dist/scope-observability.js +29 -13
  57. package/dist/services.d.ts +15 -10
  58. package/dist/services.js +59 -15
  59. package/dist/ssrf-guard.d.ts +23 -9
  60. package/dist/ssrf-guard.js +101 -62
  61. package/dist/tenant-purge.d.ts +26 -0
  62. package/dist/tenant-purge.js +67 -0
  63. package/dist/token-store.d.ts +20 -2
  64. package/dist/token-store.js +0 -0
  65. package/dist/tools/_errors.d.ts +3 -0
  66. package/dist/tools/_errors.js +53 -24
  67. package/dist/tools/_local-files.d.ts +9 -0
  68. package/dist/tools/_local-files.js +18 -0
  69. package/dist/tools/account-wizard.d.ts +10 -1
  70. package/dist/tools/account-wizard.js +74 -17
  71. package/dist/tools/accounts-tool.d.ts +7 -0
  72. package/dist/tools/accounts-tool.js +34 -18
  73. package/dist/tools/admin.d.ts +10 -1
  74. package/dist/tools/admin.js +15 -11
  75. package/dist/tools/analytics.d.ts +10 -1
  76. package/dist/tools/analytics.js +13 -8
  77. package/dist/tools/calendar.d.ts +2 -1
  78. package/dist/tools/calendar.js +18 -14
  79. package/dist/tools/chat.d.ts +10 -1
  80. package/dist/tools/chat.js +13 -8
  81. package/dist/tools/contacts.d.ts +2 -1
  82. package/dist/tools/contacts.js +17 -13
  83. package/dist/tools/docs.d.ts +2 -1
  84. package/dist/tools/docs.js +34 -30
  85. package/dist/tools/drive.d.ts +2 -1
  86. package/dist/tools/drive.js +65 -50
  87. package/dist/tools/forms.d.ts +10 -1
  88. package/dist/tools/forms.js +19 -12
  89. package/dist/tools/generated/_shared.d.ts +2 -1
  90. package/dist/tools/generated/_shared.js +7 -3
  91. package/dist/tools/generated/admin.d.ts +2 -1
  92. package/dist/tools/generated/admin.js +265 -265
  93. package/dist/tools/generated/analytics.d.ts +2 -1
  94. package/dist/tools/generated/analytics.js +125 -125
  95. package/dist/tools/generated/appsmarket.d.ts +2 -1
  96. package/dist/tools/generated/appsmarket.js +5 -5
  97. package/dist/tools/generated/calendar.d.ts +2 -1
  98. package/dist/tools/generated/calendar.js +55 -55
  99. package/dist/tools/generated/chat.d.ts +2 -1
  100. package/dist/tools/generated/chat.js +95 -95
  101. package/dist/tools/generated/classroom.d.ts +2 -1
  102. package/dist/tools/generated/classroom.js +209 -209
  103. package/dist/tools/generated/cloudidentity.d.ts +2 -1
  104. package/dist/tools/generated/cloudidentity.js +133 -133
  105. package/dist/tools/generated/cloudsearch.d.ts +2 -1
  106. package/dist/tools/generated/cloudsearch.js +99 -99
  107. package/dist/tools/generated/contacts.d.ts +2 -1
  108. package/dist/tools/generated/contacts.js +29 -29
  109. package/dist/tools/generated/drive.d.ts +2 -1
  110. package/dist/tools/generated/drive.js +71 -71
  111. package/dist/tools/generated/driveactivity.d.ts +2 -1
  112. package/dist/tools/generated/driveactivity.js +3 -3
  113. package/dist/tools/generated/drivelabels.d.ts +2 -1
  114. package/dist/tools/generated/drivelabels.js +53 -53
  115. package/dist/tools/generated/forms.d.ts +2 -1
  116. package/dist/tools/generated/forms.js +7 -7
  117. package/dist/tools/generated/gmail.d.ts +2 -1
  118. package/dist/tools/generated/gmail.js +117 -117
  119. package/dist/tools/generated/groupsmigration.d.ts +2 -1
  120. package/dist/tools/generated/groupsmigration.js +3 -3
  121. package/dist/tools/generated/groupssettings.d.ts +2 -1
  122. package/dist/tools/generated/groupssettings.js +7 -7
  123. package/dist/tools/generated/index.d.ts +2 -1
  124. package/dist/tools/generated/keep.d.ts +2 -1
  125. package/dist/tools/generated/keep.js +15 -15
  126. package/dist/tools/generated/licensing.d.ts +2 -1
  127. package/dist/tools/generated/licensing.js +15 -15
  128. package/dist/tools/generated/meet.d.ts +2 -1
  129. package/dist/tools/generated/meet.js +27 -27
  130. package/dist/tools/generated/postmaster.d.ts +2 -1
  131. package/dist/tools/generated/postmaster.js +9 -9
  132. package/dist/tools/generated/reseller.d.ts +2 -1
  133. package/dist/tools/generated/reseller.js +35 -35
  134. package/dist/tools/generated/script.d.ts +2 -1
  135. package/dist/tools/generated/script.js +33 -33
  136. package/dist/tools/generated/searchconsole.d.ts +2 -1
  137. package/dist/tools/generated/searchconsole.js +3 -3
  138. package/dist/tools/generated/sheets.d.ts +2 -1
  139. package/dist/tools/generated/sheets.js +15 -15
  140. package/dist/tools/generated/tasks.d.ts +2 -1
  141. package/dist/tools/generated/tasks.js +5 -5
  142. package/dist/tools/generated/vault.d.ts +2 -1
  143. package/dist/tools/generated/vault.js +67 -67
  144. package/dist/tools/generated/workspaceevents.d.ts +2 -1
  145. package/dist/tools/generated/workspaceevents.js +31 -31
  146. package/dist/tools/gmail.d.ts +2 -1
  147. package/dist/tools/gmail.js +45 -33
  148. package/dist/tools/google-api.d.ts +2 -3
  149. package/dist/tools/google-api.js +30 -16
  150. package/dist/tools/meet.d.ts +10 -1
  151. package/dist/tools/meet.js +13 -9
  152. package/dist/tools/searchconsole.d.ts +2 -1
  153. package/dist/tools/searchconsole.js +17 -13
  154. package/dist/tools/sheets.d.ts +2 -1
  155. package/dist/tools/sheets.js +36 -32
  156. package/dist/tools/slides.d.ts +10 -1
  157. package/dist/tools/slides.js +17 -10
  158. package/dist/tools/tasks.d.ts +2 -1
  159. package/dist/tools/tasks.js +19 -15
  160. package/dist/trim.d.ts +5 -0
  161. package/dist/trim.js +20 -0
  162. package/dist/usage-metrics.d.ts +23 -0
  163. package/dist/usage-metrics.js +65 -47
  164. package/dist/write-control.js +10 -2
  165. package/package.json +27 -1
package/dist/oauth-as.js CHANGED
@@ -5,31 +5,80 @@
5
5
  // tokens (leg C, unchanged). Mounts into B12's HttpTransportHost as `routes` +
6
6
  // an `authenticate` for POST /mcp. All Google/CIMD I/O is injectable so the
7
7
  // whole surface is exercised by an offline harness.
8
- import { createHash, randomBytes } from 'node:crypto';
8
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
9
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';
10
+ import { decodeJwt } from 'jose';
11
+ import { signAccessToken, verifyAccessToken, signState, verifyState, signAuthzCode, verifyAuthzCode, signReauthLink, verifyReauthLink, signPending, verifyPending, ReplayGuard, RefreshStore, STATE_TTL_DEFAULT, CODE_TTL_DEFAULT, ACCESS_TTL_DEFAULT, } from './mcp-token.js';
12
+ import { fetchCimdDocument } from './ssrf-guard.js';
13
+ import { CimdClientCache } from './cimd-cache.js';
14
+ import { logSafe } from './trim.js';
12
15
  const CLAUDE_AI_FIXED_CALLBACK = 'https://claude.ai/api/mcp/auth_callback';
13
16
  // 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
17
+ // bounded like every sibling store (the ReplayGuard cap) or a request loop
18
+ // OOMs the process. Cap client count (FIFO-evict the oldest) and
16
19
  // the per-request redirect_uris shape so one entry can't be arbitrarily large.
17
20
  export const DCR_MAX_CLIENTS = 1000;
18
21
  export const DCR_MAX_REDIRECT_URIS = 10;
19
22
  const DCR_MAX_URI_LEN = 2048;
23
+ const OIDC_SUB = /^[\x21-\x7e]{1,255}$/;
24
+ const HOSTED_DOMAIN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i;
25
+ // RFC 6749's error_description charset: %x20-21 / %x23-5B / %x5D-7E (printable
26
+ // ASCII minus the backslash and double-quote that would need escaping in the
27
+ // query string). A resolveSubject denial outside this set is dropped rather
28
+ // than forwarded unvalidated (H3 PR1).
29
+ const ERROR_DESCRIPTION_RE = /^[\x20-\x21\x23-\x5b\x5d-\x7e]{1,200}$/;
30
+ /** Only trust the id_token identity when Google marks the email verified (#8).
31
+ * The token comes straight from Google's token endpoint, so its claims are
32
+ * read unsigned. A malformed sub or hd is dropped, never passed on. */
33
+ export function verifiedIdentityFromIdToken(idToken) {
34
+ if (!idToken)
35
+ return undefined;
36
+ try {
37
+ const [, payload] = idToken.split('.');
38
+ const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8'));
39
+ const verified = claims.email_verified === true || claims.email_verified === 'true';
40
+ if (!verified)
41
+ return undefined;
42
+ const identity = { email: claims.email };
43
+ if (typeof claims.sub === 'string' && OIDC_SUB.test(claims.sub))
44
+ identity.sub = claims.sub;
45
+ if (typeof claims.hd === 'string' && claims.hd.length <= 253 && HOSTED_DOMAIN.test(claims.hd))
46
+ identity.hd = claims.hd;
47
+ return identity;
48
+ }
49
+ catch {
50
+ return undefined;
51
+ }
52
+ }
53
+ export function verifiedEmailFromIdToken(idToken) {
54
+ return verifiedIdentityFromIdToken(idToken)?.email;
55
+ }
20
56
  // --- helpers ----------------------------------------------------------------
21
57
  function json(res, status, body, extraHeaders = {}) {
22
58
  res.writeHead(status, { 'Content-Type': 'application/json', ...extraHeaders });
23
59
  res.end(JSON.stringify(body));
24
60
  return true;
25
61
  }
26
- function errorPage(res, status, slug, message) {
27
- res.writeHead(status, { 'Content-Type': 'text/plain' });
62
+ function errorPage(res, status, slug, message, extraHeaders = {}) {
63
+ res.writeHead(status, { 'Content-Type': 'text/plain', ...extraHeaders });
28
64
  res.end(`${slug}: ${message}`);
29
65
  return true;
30
66
  }
31
- function redirect(res, location) {
32
- res.writeHead(302, { Location: location });
67
+ class SubjectCheckUnavailable extends Error {
68
+ }
69
+ function subjectUnavailable(res) {
70
+ return json(res, 503, { error: 'temporarily_unavailable', message: 'the sign-in could not be checked right now; retry shortly' }, { 'Retry-After': '5' });
71
+ }
72
+ // Not invalid_client: clients and people read that as a permanent misconfiguration.
73
+ function clientUnavailable(res) {
74
+ return json(res, 503, { error: 'temporarily_unavailable', message: 'the client could not be checked right now; retry shortly' }, { 'Retry-After': '5' });
75
+ }
76
+ // The replay guard is full: every slot holds a jti that is still live.
77
+ function signInBusy(res) {
78
+ return errorPage(res, 503, 'E_SIGNIN_BUSY', 'sign-in is busy right now; retry in a minute', { 'Retry-After': '60' });
79
+ }
80
+ function redirect(res, location, extraHeaders = {}) {
81
+ res.writeHead(302, { Location: location, ...extraHeaders });
33
82
  res.end();
34
83
  return true;
35
84
  }
@@ -69,6 +118,38 @@ export function redirectAllowed(redirectUri, docRedirectUris) {
69
118
  return isLoopbackHostname(du.hostname) && du.protocol === r.protocol && du.pathname === r.pathname;
70
119
  });
71
120
  }
121
+ // The owner_gate browser binding (docs/internals.md). One cookie per flow, so
122
+ // two sign-ins started in one browser do not overwrite each other. __Host-
123
+ // requires Secure, which an http base (the loopback owner) cannot rely on in
124
+ // every browser, so an http base gets a plain host-only name instead.
125
+ const BIND_HASH = /^[A-Za-z0-9_-]{43}$/;
126
+ function bindCookieName(base, bind) {
127
+ return `${base.startsWith('https:') ? '__Host-' : ''}mgm-og-${bind.slice(0, 16)}`;
128
+ }
129
+ function bindCookieAttrs(base, maxAge) {
130
+ return `Path=/; Max-Age=${maxAge}; HttpOnly; SameSite=Lax${base.startsWith('https:') ? '; Secure' : ''}`;
131
+ }
132
+ function readCookie(req, name) {
133
+ for (const part of (req.headers.cookie ?? '').split(';')) {
134
+ const i = part.indexOf('=');
135
+ if (i > 0 && part.slice(0, i).trim() === name)
136
+ return part.slice(i + 1).trim();
137
+ }
138
+ return undefined;
139
+ }
140
+ function sha256Url(value) {
141
+ return createHash('sha256').update(value).digest('base64url');
142
+ }
143
+ function browserBinding(req, base, bind) {
144
+ if (typeof bind !== 'string' || !BIND_HASH.test(bind))
145
+ return 'missing';
146
+ const value = readCookie(req, bindCookieName(base, bind));
147
+ if (!value)
148
+ return 'missing';
149
+ const got = createHash('sha256').update(value).digest();
150
+ const want = Buffer.from(bind, 'base64url');
151
+ return got.length === want.length && timingSafeEqual(got, want) ? 'ok' : 'mismatch';
152
+ }
72
153
  function pkceS256(verifier) {
73
154
  return createHash('sha256').update(verifier).digest('base64url');
74
155
  }
@@ -112,14 +193,44 @@ export function buildAuthServer(config, deps = {}) {
112
193
  const base = config.base;
113
194
  const resource = config.resourceUri;
114
195
  const secret = config.secret;
196
+ // States, approvals and codes live minutes, and their single use rests on an
197
+ // in-memory replay guard that a restart empties. So they are signed with a
198
+ // key only this AuthServer holds: a restart invalidates every one in flight,
199
+ // instead of letting a spent or unreserved one pass the new, empty guard.
200
+ const flowSecret = new Uint8Array(randomBytes(32));
115
201
  const accessTtl = config.accessTtlSec ?? ACCESS_TTL_DEFAULT;
116
202
  const cimd = deps.fetchCimd ?? ((clientId) => fetchCimdDocument(clientId));
117
203
  const replay = deps.replayGuard ?? new ReplayGuard();
118
- const refresh = deps.refreshStore ?? new RefreshStore(config.refreshStorePath, config.masterKey);
119
204
  const registered = deps.registeredClients ?? new Map();
120
205
  const now = deps.now ?? (() => Date.now());
121
206
  const log = deps.log ?? (() => undefined);
207
+ // Silently dropping a lifetime the caller asked for would leave sessions unlimited.
208
+ if (deps.refreshStore && (config.refreshMaxAgeSec !== undefined || config.refreshIdleSec !== undefined || config.refreshMaxFamiliesPerSub !== undefined)) {
209
+ throw new Error('E_REFRESH_OPTION_CONFLICT: refreshMaxAgeSec, refreshIdleSec and refreshMaxFamiliesPerSub configure the refresh store buildAuthServer creates; set them on the injected refreshStore instead');
210
+ }
211
+ const refresh = deps.refreshStore ?? new RefreshStore(config.refreshStorePath, config.masterKey, { maxAgeSec: config.refreshMaxAgeSec, idleSec: config.refreshIdleSec, maxFamiliesPerSub: config.refreshMaxFamiliesPerSub, log });
122
212
  const nowSec = () => Math.floor(now() / 1000);
213
+ // A full guard is refused at whatever rate the caller likes, so its line is
214
+ // written at most once a minute (or at once after the clock stepped back).
215
+ let fullLoggedAt = -Infinity;
216
+ let fullSuppressed = 0;
217
+ const noteFull = (at) => {
218
+ if (at >= fullLoggedAt && at - fullLoggedAt < 60_000) {
219
+ fullSuppressed++;
220
+ return;
221
+ }
222
+ log(`replay guard full: sign-in refused${fullSuppressed > 0 ? ` (n=${fullSuppressed} suppressed)` : ''}`);
223
+ fullLoggedAt = at;
224
+ fullSuppressed = 0;
225
+ };
226
+ const spend = (artifact, ttlSec) => {
227
+ const at = now();
228
+ const r = replay.spend(artifact.jti, ttlSec * 1000, at, artifact.exp * 1000);
229
+ if (r === 'full')
230
+ noteFull(at);
231
+ return r;
232
+ };
233
+ const resolveSubject = deps.resolveSubject ?? ((email) => (config.ownerEmails.includes(email) ? { sub: 'owner' } : null));
123
234
  const issHeader = { 'Cache-Control': 'no-store' };
124
235
  const prm = {
125
236
  resource,
@@ -140,8 +251,7 @@ export function buildAuthServer(config, deps = {}) {
140
251
  authorization_response_iss_parameter_supported: true,
141
252
  scopes_supported: ['mcp:use'],
142
253
  };
143
- const cimdCache = new Map();
144
- const CIMD_CACHE_TTL_MS = 5 * 60_000;
254
+ const cimdClients = new CimdClientCache({ fetch: cimd, now, log });
145
255
  async function validateClient(clientId) {
146
256
  const reg = registered.get(clientId);
147
257
  if (reg)
@@ -159,52 +269,59 @@ export function buildAuthServer(config, deps = {}) {
159
269
  return { error: 'E_CIMD_INVALID', message: 'invalid client' };
160
270
  }
161
271
  if (scheme !== 'https:' || !config.cimdIssuers.includes(issuerHost)) {
162
- log(`CIMD rejected: client_id ${clientId} issuer not allowlisted`);
272
+ // The issuer and a length, never the path: an attacker picks the path freely.
273
+ log(`CIMD rejected: issuer=${logSafe(`${scheme}//${issuerHost}`, 80)} not allowlisted client_id_len=${clientId.length}`);
163
274
  return { error: 'E_CIMD_INVALID', message: 'invalid client' };
164
275
  }
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 {
276
+ // #15: cached only once it validates; the cache logs a failed fetch.
277
+ const got = await cimdClients.resolve(clientId, (doc) => doc.client_id === clientId && Array.isArray(doc.redirect_uris) && doc.redirect_uris.length > 0);
278
+ if ('busy' in got)
279
+ return got;
280
+ // #13: never echo internal fetch details to the caller (blind-SSRF oracle).
281
+ if ('error' in got)
282
+ return { error: got.error, message: 'invalid client' };
283
+ const uris = got.doc.redirect_uris;
284
+ // C6 #14: warn on a localhost-only redirect set (loopback impersonation),
285
+ // once per fetch rather than on every sign-in the cache serves.
286
+ if (got.fresh && uris.every((u) => { try {
190
287
  return isLoopbackHostname(new URL(u).hostname);
191
288
  }
192
289
  catch {
193
290
  return false;
194
291
  } })) {
195
- log(`client ${clientId} advertises only loopback redirect_uris`);
292
+ log(`client ${logSafe(clientId, 128)} advertises only loopback redirect_uris`);
196
293
  }
197
294
  return { redirect_uris: uris, dcr: false };
198
295
  }
199
296
  function escapeHtml(s) {
200
297
  return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
201
298
  }
202
- async function toGoogle(res, sp) {
203
- const state = await signState(sp, base, secret, nowSec());
299
+ /** `incoming`: the artifact spent to get here. If no slot is left for the
300
+ * new state it is unspent, so retrying it works once a slot frees. */
301
+ async function toGoogle(res, sp, incoming) {
302
+ // H4 F1: without this, a Google URL minted here and forwarded to someone
303
+ // else signs THEM in and hands their subject to the starting client.
304
+ const headers = {};
305
+ if (sp.flow === 'owner_gate') {
306
+ const value = randomBytes(32).toString('base64url');
307
+ const bind = sha256Url(value);
308
+ sp = { ...sp, bind };
309
+ headers['Set-Cookie'] = `${bindCookieName(base, bind)}=${value}; ${bindCookieAttrs(base, STATE_TTL_DEFAULT)}`;
310
+ }
311
+ const state = await signState(sp, base, flowSecret, nowSec());
312
+ // Its /callback must find a slot even while a flood fills the guard, or a
313
+ // refusal there (the browser binding) could not be recorded.
314
+ const at = now();
315
+ if (replay.reserve(String(decodeJwt(state).jti), STATE_TTL_DEFAULT * 1000, at) === 'full') {
316
+ if (incoming)
317
+ replay.unspend(incoming);
318
+ noteFull(at);
319
+ return signInBusy(res);
320
+ }
204
321
  const authUrl = deps.buildGoogleAuthUrl
205
- ? deps.buildGoogleAuthUrl({ flow: sp.flow, alias: sp.alias, state })
322
+ ? deps.buildGoogleAuthUrl({ flow: sp.flow, alias: sp.alias, state, bundles: sp.bundles })
206
323
  : `https://accounts.google.com/o/oauth2/v2/auth?state=${encodeURIComponent(state)}`;
207
- return redirect(res, authUrl);
324
+ return redirect(res, authUrl, headers);
208
325
  }
209
326
  // GET /authorize (validate + interstitial for DCR clients); POST /authorize
210
327
  // (a DCR consent approval — verify the signed pending token, then proceed).
@@ -219,17 +336,25 @@ export function buildAuthServer(config, deps = {}) {
219
336
  }
220
337
  let pending;
221
338
  try {
222
- pending = await verifyPending(form.pending ?? '', base, secret);
339
+ pending = await verifyPending(form.pending ?? '', base, flowSecret, nowSec());
223
340
  }
224
341
  catch {
225
342
  return errorPage(res, 400, 'E_STATE_INVALID', 'the approval is invalid or expired');
226
343
  }
227
- if (!replay.consume(pending.jti, STATE_TTL_DEFAULT * 1000, now())) {
228
- return errorPage(res, 400, 'E_STATE_INVALID', 'this approval was already used');
344
+ // Only the client leg signs pendings, and it is always the owner sign-in.
345
+ // Defence in depth: a pending from an earlier release, which could carry
346
+ // flow=alias_reauth, was signed with MCP_JWT_KEY and no longer verifies.
347
+ if (pending.flow !== 'owner_gate') {
348
+ return errorPage(res, 400, 'E_STATE_INVALID', 'the approval is invalid or expired');
229
349
  }
350
+ const spent = spend(pending, STATE_TTL_DEFAULT);
351
+ if (spent === 'full')
352
+ return signInBusy(res);
353
+ if (spent === 'replay')
354
+ return errorPage(res, 400, 'E_STATE_INVALID', 'this approval was already used');
230
355
  const { jti: _drop, ...sp } = pending;
231
356
  void _drop;
232
- return toGoogle(res, sp);
357
+ return toGoogle(res, sp, pending.jti);
233
358
  }
234
359
  const q = url.searchParams;
235
360
  const clientId = q.get('client_id') ?? '';
@@ -239,20 +364,53 @@ export function buildAuthServer(config, deps = {}) {
239
364
  const resourceParam = q.get('resource') ?? '';
240
365
  const clientState = q.get('state') ?? undefined;
241
366
  const aliasParam = q.get('alias') ?? undefined;
367
+ // Server-minted alias_add link: the whole request identity (tenant, alias,
368
+ // bundles) lives inside the SIGNED state — verify, spend it (single-use),
369
+ // then re-sign fresh for the Google leg. A tampered tenantId breaks the
370
+ // signature; a caller cannot supply one at all.
371
+ if (q.get('flow') === 'alias_add' && !clientId) {
372
+ let minted;
373
+ try {
374
+ minted = await verifyState(q.get('state') ?? '', base, flowSecret, nowSec());
375
+ }
376
+ catch {
377
+ return errorPage(res, 400, 'E_STATE_INVALID', 'the add-account link is invalid, expired, or tampered');
378
+ }
379
+ if (minted.flow !== 'alias_add' || !minted.alias || !minted.tenantId) {
380
+ return errorPage(res, 400, 'E_STATE_INVALID', 'the add-account link is malformed');
381
+ }
382
+ const spent = spend(minted, STATE_TTL_DEFAULT);
383
+ if (spent === 'full')
384
+ return signInBusy(res);
385
+ if (spent === 'replay')
386
+ return errorPage(res, 400, 'E_STATE_INVALID', 'this add-account link was already used');
387
+ const { jti: _spent, ...sp } = minted;
388
+ void _spent;
389
+ return toGoogle(res, sp, minted.jti);
390
+ }
242
391
  // In-call re-auth (BV-1): a user-clicked `flow=alias_reauth` link has NO
243
392
  // client leg — no code is delivered to any client, the refreshed token is
244
393
  // 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}"`);
394
+ // PKCE requirements. The link must be one this server signed (reauthLink),
395
+ // and /callback still binds the completing identity to the alias's email.
396
+ if (q.get('flow') === 'alias_reauth' && !clientId) {
397
+ if (config.legacyAliasReauth === false) {
398
+ // Alias-only, tenant-ambiguous: disabled on multi-tenant deployments.
399
+ return errorPage(res, 403, 'E_LEGACY_REAUTH_DISABLED', 'alias re-auth links are disabled on this deployment; ask the server for a fresh account link');
250
400
  }
251
- return toGoogle(res, { flow: 'alias_reauth', client_id: '', redirect_uri: '', code_challenge: '', resource, alias: aliasParam });
401
+ const alias = verifyReauthLink(base, secret, { alias: aliasParam ?? '', exp: q.get('exp') ?? '', sig: q.get('sig') ?? '' }, nowSec());
402
+ // One answer for an unsigned, forged, expired or stale link, so a
403
+ // request the server did not issue learns nothing about its aliases.
404
+ if (!alias || !deps.aliasEmail?.(alias)) {
405
+ return errorPage(res, 400, 'E_STATE_INVALID', 'this re-auth link is invalid or expired; ask the server for a fresh one');
406
+ }
407
+ return toGoogle(res, { flow: 'alias_reauth', client_id: '', redirect_uri: '', code_challenge: '', resource, alias });
252
408
  }
253
409
  if (!clientId)
254
410
  return json(res, 400, { error: 'invalid_request', message: 'client_id required', iss: base }, issHeader);
255
411
  const client = await validateClient(clientId);
412
+ if ('busy' in client)
413
+ return clientUnavailable(res);
256
414
  if ('error' in client)
257
415
  return json(res, 400, { error: 'invalid_client', message: client.message, iss: base }, issHeader);
258
416
  // (2) exact redirect_uri match BEFORE minting anything (open-redirect defense)
@@ -268,14 +426,16 @@ export function buildAuthServer(config, deps = {}) {
268
426
  return json(res, 400, { error: 'invalid_request', message: `resource must be ${resource}`, iss: base }, issHeader);
269
427
  }
270
428
  // (5) only after full validation do we build the request artifact.
429
+ // A client leg is always the owner sign-in. Re-auth has its own signed
430
+ // link: accepting flow=alias_reauth here let any client (a self-registered
431
+ // one, or a public CIMD client_id) start it and read the alias's scopes.
271
432
  const statePayload = {
272
- flow: q.get('flow') === 'alias_reauth' ? 'alias_reauth' : 'owner_gate',
433
+ flow: 'owner_gate',
273
434
  client_id: clientId,
274
435
  redirect_uri: redirectUri,
275
436
  code_challenge: codeChallenge,
276
437
  client_state: clientState,
277
438
  resource,
278
- alias: q.get('alias') ?? undefined,
279
439
  };
280
440
  // Confused-deputy defense (#3): a self-registered DCR client can pick an
281
441
  // arbitrary redirect_uri, so before we send the owner to Google (whose
@@ -284,8 +444,8 @@ export function buildAuthServer(config, deps = {}) {
284
444
  // it — their redirect_uris come from an issuer-allowlisted document a rogue
285
445
  // cannot forge, so there is nothing to spoof.
286
446
  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' });
447
+ const pendingToken = await signPending(statePayload, base, flowSecret, nowSec());
448
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Content-Security-Policy': "frame-ancestors 'none'", 'X-Frame-Options': 'DENY' });
289
449
  res.end(`<!doctype html><meta charset=utf-8><title>Authorize access</title>` +
290
450
  `<body style="font-family:system-ui,sans-serif;max-width:36em;margin:3em auto;line-height:1.5">` +
291
451
  `<h2>Authorize access?</h2>` +
@@ -304,13 +464,25 @@ export function buildAuthServer(config, deps = {}) {
304
464
  const stateToken = url.searchParams.get('state') ?? '';
305
465
  let st;
306
466
  try {
307
- st = await verifyState(stateToken, base, secret);
467
+ st = await verifyState(stateToken, base, flowSecret, nowSec());
308
468
  }
309
469
  catch {
310
470
  return errorPage(res, 400, 'E_STATE_INVALID', 'state is invalid, expired, or tampered');
311
471
  }
312
- if (!replay.consume(st.jti, STATE_TTL_DEFAULT * 1000, now())) {
472
+ const spent = spend(st, STATE_TTL_DEFAULT);
473
+ if (spent === 'full')
474
+ return signInBusy(res);
475
+ if (spent === 'replay')
313
476
  return errorPage(res, 400, 'E_STATE_INVALID', 'state has already been used (replay)');
477
+ // After the spend, so a refused callback URL cannot be replayed from the
478
+ // browser that holds the cookie; before the exchange and resolveSubject.
479
+ if (st.flow === 'owner_gate') {
480
+ const binding = browserBinding(req, base, st.bind);
481
+ if (binding !== 'ok') {
482
+ log(`callback refused flow=owner_gate: browser binding ${binding}`);
483
+ return errorPage(res, 400, 'E_BROWSER_MISMATCH', 'finish the sign-in in the browser that started it; start again from your app');
484
+ }
485
+ res.setHeader('Set-Cookie', `${bindCookieName(base, st.bind ?? '')}=; ${bindCookieAttrs(base, 0)}`);
314
486
  }
315
487
  if (!code)
316
488
  return errorPage(res, 400, 'invalid_request', 'missing code');
@@ -319,7 +491,40 @@ export function buildAuthServer(config, deps = {}) {
319
491
  exchanged = deps.exchangeCode ? await deps.exchangeCode(code, st.flow) : { tokens: {} };
320
492
  }
321
493
  catch (e) {
322
- return errorPage(res, 400, 'invalid_grant', `Google code exchange failed: ${e.message}`);
494
+ // The detail can name proxies and hosts; it goes to the log, not the browser.
495
+ const detail = e instanceof Error ? e.message : typeof e === 'string' ? e : 'non-Error rejection';
496
+ log(`callback exchange failed flow=${st.flow}: ${logSafe(detail, 300)}`);
497
+ return errorPage(res, 400, 'invalid_grant', 'Google could not complete the sign-in; start again from your app');
498
+ }
499
+ if (st.flow === 'alias_add') {
500
+ if (!st.alias || !st.tenantId)
501
+ return errorPage(res, 400, 'invalid_request', 'alias_add without an alias/tenant binding');
502
+ if (!deps.bindTenantAlias)
503
+ return errorPage(res, 500, 'E_ALIAS_ADD_UNAVAILABLE', 'account linking is not enabled on this server');
504
+ const boundEmail = (exchanged.email ?? '').toLowerCase();
505
+ if (!boundEmail)
506
+ return errorPage(res, 403, 'access_denied', 'Google did not return an email for the signed-in account');
507
+ let refusal;
508
+ try {
509
+ const out = await deps.bindTenantAlias({ tenantId: st.tenantId, alias: st.alias, email: boundEmail, bundles: st.bundles, nonce: st.nonce, tokens: exchanged.tokens, sub: exchanged.sub });
510
+ refusal = out?.refused;
511
+ }
512
+ catch (e) {
513
+ // Any rejection reason, even none, must still reach this answer.
514
+ log(`alias_add bind failed for "${logSafe(st.alias, 64)}": ${logSafe(e instanceof Error ? e.message : typeof e === 'string' ? e : 'non-Error rejection', 200)}`);
515
+ return errorPage(res, 500, 'E_ALIAS_ADD_FAILED', 'the account could not be saved; try the link again or ask for a fresh one');
516
+ }
517
+ if (refusal) {
518
+ // The slug lands in a text/plain body; keep it a bare identifier.
519
+ const slug = typeof refusal.slug === 'string' && /^[A-Za-z0-9_]+$/.test(refusal.slug) ? refusal.slug : 'access_denied';
520
+ const message = typeof refusal.message === 'string' && refusal.message ? refusal.message : 'the account could not be linked';
521
+ log(`alias_add refused for "${logSafe(st.alias, 64)}": ${logSafe(slug, 64)}`);
522
+ return errorPage(res, 403, slug, message);
523
+ }
524
+ log(`callback ok flow=alias_add alias=${logSafe(st.alias, 64)}`);
525
+ res.writeHead(200, { 'Content-Type': 'text/html' });
526
+ res.end(`<!doctype html><meta charset=utf-8><p>Connected "${escapeHtml(st.alias)}". You can close this window and retry your request.</p>`);
527
+ return true;
323
528
  }
324
529
  if (st.flow === 'alias_reauth') {
325
530
  if (!st.alias)
@@ -332,24 +537,54 @@ export function buildAuthServer(config, deps = {}) {
332
537
  const gotEmail = (exchanged.email ?? '').toLowerCase();
333
538
  const expected = (deps.aliasEmail?.(st.alias) ?? '').toLowerCase();
334
539
  if (!expected || !gotEmail || gotEmail !== expected) {
335
- log(`alias_reauth identity mismatch for "${st.alias}" (got ${gotEmail || 'none'})`);
540
+ log(`alias_reauth identity mismatch for "${logSafe(st.alias, 64)}" (got ${logSafe(gotEmail || 'none', 254)})`);
336
541
  return errorPage(res, 403, 'access_denied', 'the Google account you signed in with is not the one configured for this alias');
337
542
  }
543
+ // A completion without a refresh token (a Google URL built without
544
+ // offline access, say) would replace a working credential with one that
545
+ // dies within the hour. Keep the stored token instead.
546
+ const refreshToken = exchanged.tokens.refresh_token;
547
+ if (typeof refreshToken !== 'string' || refreshToken === '') {
548
+ log(`alias_reauth for "${logSafe(st.alias, 64)}" returned no refresh token; stored token kept`);
549
+ return errorPage(res, 400, 'E_REAUTH_INCOMPLETE', 'Google did not return a long-lived token, so the stored one was kept. Open the re-auth link again and approve access.');
550
+ }
551
+ const granted = typeof exchanged.tokens.scope === 'string' ? exchanged.tokens.scope : undefined;
552
+ const missing = deps.missingScopes?.(st.alias, granted) ?? [];
553
+ const listed = missing.map((m) => `<code>${escapeHtml(m)}</code>`).join(', ');
554
+ // A narrower grant replaces nothing that works. The link can be reused
555
+ // for an hour and the Google URL's scope is not signed, so a narrower
556
+ // grant is not necessarily the account holder's choice.
557
+ if (missing.length > 0 && deps.hasToken?.(st.alias)) {
558
+ log(`alias_reauth for "${logSafe(st.alias, 64)}" granted ${missing.length} fewer scope(s); stored token kept`);
559
+ res.writeHead(400, { 'Content-Type': 'text/html' });
560
+ res.end(`<!doctype html><meta charset=utf-8><p>E_SCOPE_NOT_GRANTED: Google did not grant ${missing.length} requested scope(s): ${listed}, so the stored access for "${escapeHtml(st.alias)}" was kept. Ask for a fresh re-auth link and leave every box ticked.</p>`);
561
+ return true;
562
+ }
338
563
  deps.writeToken?.(st.alias, exchanged.tokens);
339
- log(`callback ok flow=alias_reauth alias=${st.alias}`);
564
+ log(`callback ok flow=alias_reauth alias=${logSafe(st.alias, 64)}`);
565
+ // With no stored token a partial grant still beats none; say what is missing.
566
+ const gap = missing.length
567
+ ? `<p>Google did not grant ${missing.length} requested scope(s): ${listed}. Ask for a fresh re-auth link and leave every box ticked to restore them.</p>`
568
+ : '';
340
569
  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>`);
570
+ res.end(`<!doctype html><meta charset=utf-8><p>Re-authenticated "${escapeHtml(st.alias)}". You can close this window.</p>${gap}`);
342
571
  return true;
343
572
  }
344
- // owner_gate: verify the email is an allowlisted owner, discard Google tokens
573
+ // owner_gate: resolve the verified email to its subject (default = the
574
+ // owner allowlist -> 'owner'), discard Google tokens. The resolved sub is
575
+ // signed into the authz code and flows into every token minted from it.
345
576
  const email = (exchanged.email ?? '').toLowerCase();
346
577
  const iss = encodeURIComponent(base);
347
- if (!email || !config.ownerEmails.includes(email)) {
578
+ const resolved = email ? resolveSubject(email, { sub: exchanged.sub, hd: exchanged.hd }) : null;
579
+ if (!resolved || 'denied' in resolved) {
348
580
  const sep = st.redirect_uri.includes('?') ? '&' : '?';
349
581
  const s = st.client_state ? `&state=${encodeURIComponent(st.client_state)}` : '';
350
- return redirect(res, `${st.redirect_uri}${sep}error=access_denied${s}&iss=${iss}`);
582
+ const desc = resolved && 'denied' in resolved ? resolved.denied.description : undefined;
583
+ const d = desc !== undefined && ERROR_DESCRIPTION_RE.test(desc) ? `&error_description=${encodeURIComponent(desc)}` : '';
584
+ return redirect(res, `${st.redirect_uri}${sep}error=access_denied${d}${s}&iss=${iss}`);
351
585
  }
352
- const authzCode = await signAuthzCode({ redirect_uri: st.redirect_uri, code_challenge: st.code_challenge, resource: st.resource, sub: 'owner' }, base, secret, nowSec());
586
+ const subject = resolved;
587
+ const authzCode = await signAuthzCode({ redirect_uri: st.redirect_uri, code_challenge: st.code_challenge, resource: st.resource, sub: subject.sub }, base, flowSecret, nowSec());
353
588
  log('callback ok flow=owner_gate');
354
589
  const sep = st.redirect_uri.includes('?') ? '&' : '?';
355
590
  const s = st.client_state ? `&state=${encodeURIComponent(st.client_state)}` : '';
@@ -368,14 +603,16 @@ export function buildAuthServer(config, deps = {}) {
368
603
  if (grant === 'authorization_code') {
369
604
  let code;
370
605
  try {
371
- code = await verifyAuthzCode(form.code ?? '', base, secret);
606
+ code = await verifyAuthzCode(form.code ?? '', base, flowSecret, nowSec());
372
607
  }
373
608
  catch {
374
609
  return json(res, 400, { error: 'invalid_grant', message: 'authorization code invalid or expired' });
375
610
  }
376
- if (!replay.consume(code.jti, CODE_TTL_DEFAULT * 1000, now())) {
611
+ const spent = spend(code, CODE_TTL_DEFAULT);
612
+ if (spent === 'full')
613
+ return subjectUnavailable(res);
614
+ if (spent === 'replay')
377
615
  return json(res, 400, { error: 'invalid_grant', message: 'authorization code already redeemed' });
378
- }
379
616
  if (form.redirect_uri !== code.redirect_uri) {
380
617
  return json(res, 400, { error: 'invalid_grant', message: 'redirect_uri mismatch' });
381
618
  }
@@ -385,18 +622,58 @@ export function buildAuthServer(config, deps = {}) {
385
622
  if (!form.code_verifier || pkceS256(form.code_verifier) !== code.code_challenge) {
386
623
  return json(res, 400, { error: 'invalid_grant', message: 'PKCE verification failed' });
387
624
  }
388
- const access = await signAccessToken({ base, secret, iat: nowSec(), ttlSec: accessTtl });
389
- const refreshToken = refresh.issue(now());
625
+ const access = await signAccessToken({ base, secret, iat: nowSec(), ttlSec: accessTtl, sub: code.sub });
626
+ if (deps.subjectActive) {
627
+ let active;
628
+ try {
629
+ active = deps.subjectActive(code.sub);
630
+ }
631
+ catch (e) {
632
+ log(`token deferred grant=authorization_code: subject check failed: ${e instanceof Error ? logSafe(e.message, 200) : 'non-Error throw'}`);
633
+ // Nothing was issued and PKCE passed, so the retry the 503 asks for may redeem it.
634
+ replay.unspend(code.jti);
635
+ return subjectUnavailable(res);
636
+ }
637
+ if (!active) {
638
+ log('token refused grant=authorization_code: subject inactive');
639
+ return json(res, 400, { error: 'invalid_grant', message: 'the subject is no longer provisioned' });
640
+ }
641
+ }
642
+ const refreshToken = refresh.issue(now(), code.sub);
390
643
  log('token issued grant=authorization_code');
391
644
  return json(res, 200, { access_token: access, token_type: 'Bearer', expires_in: accessTtl, refresh_token: refreshToken, scope: 'mcp:use' });
392
645
  }
393
646
  if (grant === 'refresh_token') {
394
- const next = refresh.rotate(form.refresh_token ?? '', now());
647
+ const subjectActive = deps.subjectActive;
648
+ const accept = subjectActive
649
+ ? (sub) => {
650
+ let active;
651
+ try {
652
+ active = subjectActive(sub);
653
+ }
654
+ catch (e) {
655
+ throw new SubjectCheckUnavailable('subject check failed', { cause: e });
656
+ }
657
+ if (!active)
658
+ log('token refused grant=refresh_token: subject inactive');
659
+ return active;
660
+ }
661
+ : undefined;
662
+ let next;
663
+ try {
664
+ next = refresh.rotate(form.refresh_token ?? '', now(), accept);
665
+ }
666
+ catch (e) {
667
+ if (!(e instanceof SubjectCheckUnavailable))
668
+ throw e;
669
+ log(`token deferred grant=refresh_token: subject check failed: ${e.cause instanceof Error ? logSafe(e.cause.message, 200) : 'non-Error throw'}`);
670
+ return subjectUnavailable(res);
671
+ }
395
672
  if (!next)
396
673
  return json(res, 400, { error: 'invalid_grant', message: 'unknown or rotated refresh token' });
397
- const access = await signAccessToken({ base, secret, iat: nowSec(), ttlSec: accessTtl });
674
+ const access = await signAccessToken({ base, secret, iat: nowSec(), ttlSec: accessTtl, sub: next.sub });
398
675
  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' });
676
+ return json(res, 200, { access_token: access, token_type: 'Bearer', expires_in: accessTtl, refresh_token: next.token, scope: 'mcp:use' });
400
677
  }
401
678
  return json(res, 400, { error: 'unsupported_grant_type', message: 'authorization_code or refresh_token only' });
402
679
  };
@@ -410,9 +687,18 @@ export function buildAuthServer(config, deps = {}) {
410
687
  catch {
411
688
  return json(res, 400, { error: 'invalid_client_metadata', message: 'a JSON body with redirect_uris is required' });
412
689
  }
413
- const redirectUris = Array.isArray(parsed.redirect_uris) ? parsed.redirect_uris.map(String) : [];
690
+ // Shape first: a throw here is an unauthenticated 500 per request, and a
691
+ // `null` body has no properties while String() throws on {"toString":1}.
692
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
693
+ return json(res, 400, { error: 'invalid_client_metadata', message: 'a JSON body with redirect_uris is required' });
694
+ }
695
+ const raw = parsed.redirect_uris;
696
+ const redirectUris = Array.isArray(raw) ? raw : [];
414
697
  if (redirectUris.length === 0)
415
698
  return json(res, 400, { error: 'invalid_client_metadata', message: 'redirect_uris required' });
699
+ if (!redirectUris.every((u) => typeof u === 'string')) {
700
+ return json(res, 400, { error: 'invalid_client_metadata', message: 'every redirect_uri must be a string' });
701
+ }
416
702
  if (redirectUris.length > DCR_MAX_REDIRECT_URIS || redirectUris.some((u) => u.length > DCR_MAX_URI_LEN)) {
417
703
  return json(res, 400, { error: 'invalid_client_metadata', message: 'too many or oversized redirect_uris' });
418
704
  }
@@ -446,12 +732,21 @@ export function buildAuthServer(config, deps = {}) {
446
732
  return { ok: false, status: 401, headers: { 'WWW-Authenticate': wwwAuth }, body: JSON.stringify({ error: 'E_MCP_TOKEN_INVALID', message: 'missing bearer token' }) };
447
733
  }
448
734
  try {
449
- await verifyAccessToken(header.slice(7), base, secret);
450
- return { ok: true };
735
+ const claims = await verifyAccessToken(header.slice(7), base, secret);
736
+ return { ok: true, sub: claims.sub };
451
737
  }
452
738
  catch {
453
739
  return { ok: false, status: 401, headers: { 'WWW-Authenticate': wwwAuth }, body: JSON.stringify({ error: 'E_MCP_TOKEN_INVALID', message: 'invalid or expired token' }) };
454
740
  }
455
741
  };
456
- return { routes, authenticate };
742
+ const mintFlowState = async ({ flow, alias, tenantId, bundles, nonce }) => {
743
+ // /authorize has a signed branch for alias_add only; anything else minted
744
+ // here would be a state no route verifies the way its flow needs.
745
+ if (flow !== 'alias_add')
746
+ throw new Error(`mintFlowState supports alias_add only, not ${String(flow)}`);
747
+ const state = await signState({ flow, client_id: '', redirect_uri: '', code_challenge: '', resource, alias, tenantId, bundles, nonce }, base, flowSecret, nowSec());
748
+ return `${base}/authorize?flow=${flow}&state=${encodeURIComponent(state)}`;
749
+ };
750
+ const reauthLink = (alias) => `${base}/authorize?flow=alias_reauth&${signReauthLink(base, secret, alias, nowSec())}`;
751
+ return { routes, authenticate, mintFlowState, reauthLink };
457
752
  }