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

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