@ractive-ch/tab-mcp 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/main.js CHANGED
@@ -10,6 +10,7 @@ var DEFAULT_CONFIG = {
10
10
  host: "0.0.0.0",
11
11
  allowedOrigins: [],
12
12
  sites: [],
13
+ identity: "code",
13
14
  clientIdHosts: [],
14
15
  trustProxy: "none",
15
16
  cors: true,
@@ -77,6 +78,7 @@ function resolveConfig(input = {}) {
77
78
  }
78
79
  seen.add(site.origin);
79
80
  }
81
+ validateIdentity(config);
80
82
  config.clientIdHosts = config.clientIdHosts.map((h) => {
81
83
  const host = h.trim().toLowerCase();
82
84
  if (!/^[a-z0-9.-]+(:\d+)?$/.test(host)) {
@@ -159,13 +161,12 @@ function validateSite(input) {
159
161
  out.name = site["name"];
160
162
  }
161
163
  const pairing = site["pairing"];
162
- if (pairing !== undefined && pairing !== "code") {
163
- if (pairing === "oidc") {
164
- throw new ConfigError(`pairing "oidc" (identity binding) is planned but not available yet: ${out.origin}`);
164
+ if (pairing !== undefined) {
165
+ if (pairing !== "code" && pairing !== "oidc") {
166
+ throw new ConfigError(`pairing must be "code" or "oidc": ${out.origin}`);
165
167
  }
166
- throw new ConfigError(`pairing must be "code": ${out.origin}`);
168
+ out.pairing = pairing;
167
169
  }
168
- out.pairing = "code";
169
170
  const limits = site["limits"];
170
171
  if (limits !== undefined) {
171
172
  if (typeof limits !== "object" || limits === null) {
@@ -189,6 +190,57 @@ function validateSite(input) {
189
190
  }
190
191
  return out;
191
192
  }
193
+ function checkUrl(value, name, development) {
194
+ if (typeof value !== "string" || value === "") {
195
+ throw new ConfigError(`${name} is required for identity binding`);
196
+ }
197
+ let url;
198
+ try {
199
+ url = new URL(value);
200
+ } catch {
201
+ throw new ConfigError(`${name} is not a URL: ${value}`);
202
+ }
203
+ if (url.protocol !== "https:" && !(development && url.protocol === "http:")) {
204
+ throw new ConfigError(`${name} must be https: ${value}`);
205
+ }
206
+ if (url.search !== "" || url.hash !== "" || url.username !== "" || url.password !== "") {
207
+ throw new ConfigError(`${name} has no query, fragment or credentials: ${value}`);
208
+ }
209
+ return value;
210
+ }
211
+ function validateIdentity(config) {
212
+ if (config.identity !== "code" && config.identity !== "oidc") {
213
+ throw new ConfigError('identity must be "code" or "oidc"');
214
+ }
215
+ const used = config.sites.some((s) => (s.pairing ?? config.identity) === "oidc") || config.identity === "oidc" && config.allowedOrigins !== "*" && config.allowedOrigins.length > 0;
216
+ if (config.identity === "oidc" && config.allowedOrigins === "*") {
217
+ throw new ConfigError("TAB_MCP_IDENTITY=oidc cannot be used with TAB_MCP_ALLOWED_ORIGINS=* (one provider cannot vouch for any website's users)");
218
+ }
219
+ const oidc = config.oidc;
220
+ if (oidc === undefined) {
221
+ if (used) {
222
+ throw new ConfigError("identity binding (oidc) needs TAB_MCP_OIDC_ISSUER, TAB_MCP_OIDC_CLIENT_ID, TAB_MCP_TAB_ASSERTION_ISSUER and TAB_MCP_TAB_ASSERTION_JWKS_URL");
223
+ }
224
+ return;
225
+ }
226
+ const dev = config.development;
227
+ checkUrl(oidc.issuer, "TAB_MCP_OIDC_ISSUER", dev);
228
+ checkUrl(oidc.assertionJwksUrl, "TAB_MCP_TAB_ASSERTION_JWKS_URL", dev);
229
+ if (typeof oidc.clientId !== "string" || oidc.clientId === "") {
230
+ throw new ConfigError("TAB_MCP_OIDC_CLIENT_ID is required for identity binding");
231
+ }
232
+ if (oidc.clientSecret !== undefined && typeof oidc.clientSecret !== "string") {
233
+ throw new ConfigError("TAB_MCP_OIDC_CLIENT_SECRET must be a string");
234
+ }
235
+ if (typeof oidc.assertionIssuer !== "string" || oidc.assertionIssuer === "") {
236
+ throw new ConfigError("TAB_MCP_TAB_ASSERTION_ISSUER is required for identity binding");
237
+ }
238
+ const claim = oidc.subjectClaim ?? "sub";
239
+ if (claim !== "sub" && claim !== "oid") {
240
+ throw new ConfigError('TAB_MCP_OIDC_SUBJECT_CLAIM must be "sub" or "oid" (a stable identifier, never email or a user name)');
241
+ }
242
+ config.oidc = { ...oidc, subjectClaim: claim };
243
+ }
192
244
  function defaultLimits(config) {
193
245
  return {
194
246
  maxMessageBytes: config.maxMessageBytes,
@@ -206,7 +258,7 @@ function siteFor(config, origin) {
206
258
  if (configured !== undefined) {
207
259
  const site = {
208
260
  origin: configured.origin,
209
- pairing: configured.pairing ?? "code",
261
+ pairing: configured.pairing ?? config.identity,
210
262
  limits: { ...defaults, ...configured.limits }
211
263
  };
212
264
  if (configured.name !== undefined)
@@ -216,7 +268,7 @@ function siteFor(config, origin) {
216
268
  if (config.allowedOrigins === "*" || origin !== null && config.allowedOrigins.includes(origin)) {
217
269
  return {
218
270
  origin: config.allowedOrigins === "*" ? "*" : origin ?? "",
219
- pairing: "code",
271
+ pairing: config.allowedOrigins === "*" ? "code" : config.identity,
220
272
  limits: defaults
221
273
  };
222
274
  }
@@ -300,6 +352,30 @@ function configFromEnv(env) {
300
352
  if (hosts !== undefined && hosts.trim() !== "") {
301
353
  out.clientIdHosts = hosts.split(",").map((h) => h.trim()).filter((h) => h !== "");
302
354
  }
355
+ set("identity", oneOf(env, "TAB_MCP_IDENTITY", ["code", "oidc"]));
356
+ const oidcVars = {
357
+ issuer: env["TAB_MCP_OIDC_ISSUER"],
358
+ clientId: env["TAB_MCP_OIDC_CLIENT_ID"],
359
+ clientSecret: env["TAB_MCP_OIDC_CLIENT_SECRET"],
360
+ subjectClaim: env["TAB_MCP_OIDC_SUBJECT_CLAIM"],
361
+ assertionIssuer: env["TAB_MCP_TAB_ASSERTION_ISSUER"],
362
+ assertionJwksUrl: env["TAB_MCP_TAB_ASSERTION_JWKS_URL"]
363
+ };
364
+ if (Object.values(oidcVars).some((v) => v !== undefined && v !== "")) {
365
+ const oidc = {
366
+ issuer: oidcVars.issuer ?? "",
367
+ clientId: oidcVars.clientId ?? "",
368
+ assertionIssuer: oidcVars.assertionIssuer ?? "",
369
+ assertionJwksUrl: oidcVars.assertionJwksUrl ?? ""
370
+ };
371
+ if (oidcVars.clientSecret !== undefined && oidcVars.clientSecret !== "") {
372
+ oidc.clientSecret = oidcVars.clientSecret;
373
+ }
374
+ if (oidcVars.subjectClaim !== undefined && oidcVars.subjectClaim !== "") {
375
+ oidc.subjectClaim = oidcVars.subjectClaim;
376
+ }
377
+ out.oidc = oidc;
378
+ }
303
379
  set("trustProxy", oneOf(env, "TAB_MCP_TRUST_PROXY", [
304
380
  "none",
305
381
  "x-real-ip",
@@ -525,7 +601,7 @@ function installCrashLogging(logger, exit = (code) => process.exit(code)) {
525
601
  };
526
602
  }
527
603
  // package.json
528
- var version = "0.2.1";
604
+ var version = "0.3.0";
529
605
 
530
606
  // src/address.ts
531
607
  var BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
@@ -574,7 +650,8 @@ var RelayErrorCode = {
574
650
  sendFailed: -31005,
575
651
  tooLarge: -31006,
576
652
  badResponse: -31007,
577
- storeUnavailable: -31008
653
+ storeUnavailable: -31008,
654
+ signedOut: -31009
578
655
  };
579
656
  var encoder = new TextEncoder;
580
657
  var NULL_BODY_STATUSES = new Set([204, 205, 304]);
@@ -884,6 +961,386 @@ class Exchange {
884
961
  }
885
962
  }
886
963
 
964
+ // src/jwt.ts
965
+ var JWT_ALGORITHMS = [
966
+ "RS256",
967
+ "RS384",
968
+ "RS512",
969
+ "PS256",
970
+ "PS384",
971
+ "PS512",
972
+ "ES256",
973
+ "ES384",
974
+ "ES512",
975
+ "EdDSA"
976
+ ];
977
+ var MAX_JWT_BYTES = 8192;
978
+ var MAX_JWKS_BYTES = 65536;
979
+ var JWKS_TTL_MS = 10 * 60000;
980
+ var JWKS_REFETCH_MS = 30000;
981
+ var JWKS_GRACE_MS = 60 * 60000;
982
+ var FETCH_TIMEOUT_MS = 5000;
983
+
984
+ class JwtError extends Error {
985
+ name = "JwtError";
986
+ reason;
987
+ constructor(reason, message) {
988
+ super(message);
989
+ this.reason = reason;
990
+ }
991
+ }
992
+ function fromBase64Url(part) {
993
+ if (!/^[A-Za-z0-9_-]*$/.test(part)) {
994
+ throw new JwtError("malformed", "the token is not base64url");
995
+ }
996
+ return Uint8Array.fromBase64(part, { alphabet: "base64url" });
997
+ }
998
+ function jsonPart(part, what) {
999
+ let value;
1000
+ try {
1001
+ value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(fromBase64Url(part)));
1002
+ } catch {
1003
+ throw new JwtError("malformed", `the token's ${what} is not JSON`);
1004
+ }
1005
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
1006
+ throw new JwtError("malformed", `the token's ${what} is not a JSON object`);
1007
+ }
1008
+ return value;
1009
+ }
1010
+ function decodeJwt(token) {
1011
+ if (typeof token !== "string" || token.length > MAX_JWT_BYTES) {
1012
+ throw new JwtError("too_large", "the token is too large");
1013
+ }
1014
+ const parts = token.split(".");
1015
+ if (parts.length !== 3) {
1016
+ throw new JwtError("malformed", "the token is not a signed JWT");
1017
+ }
1018
+ const [h = "", p = "", s = ""] = parts;
1019
+ return {
1020
+ header: jsonPart(h, "header"),
1021
+ payload: jsonPart(p, "payload"),
1022
+ signingInput: new TextEncoder().encode(`${h}.${p}`),
1023
+ signature: fromBase64Url(s)
1024
+ };
1025
+ }
1026
+ function spec(alg) {
1027
+ const bits = alg.slice(2);
1028
+ const hash = `SHA-${bits}`;
1029
+ switch (alg.slice(0, 2)) {
1030
+ case "RS":
1031
+ return {
1032
+ kty: "RSA",
1033
+ importParams: { name: "RSASSA-PKCS1-v1_5", hash },
1034
+ verifyParams: { name: "RSASSA-PKCS1-v1_5" }
1035
+ };
1036
+ case "PS":
1037
+ return {
1038
+ kty: "RSA",
1039
+ importParams: { name: "RSA-PSS", hash },
1040
+ verifyParams: {
1041
+ name: "RSA-PSS",
1042
+ saltLength: Number(bits) / 8
1043
+ }
1044
+ };
1045
+ case "ES": {
1046
+ const crv = bits === "512" ? "P-521" : `P-${bits}`;
1047
+ return {
1048
+ kty: "EC",
1049
+ crv,
1050
+ importParams: { name: "ECDSA", namedCurve: crv },
1051
+ verifyParams: { name: "ECDSA", hash }
1052
+ };
1053
+ }
1054
+ default:
1055
+ return {
1056
+ kty: "OKP",
1057
+ crv: "Ed25519",
1058
+ importParams: { name: "Ed25519" },
1059
+ verifyParams: { name: "Ed25519" }
1060
+ };
1061
+ }
1062
+ }
1063
+ function isAlgorithm(v) {
1064
+ return JWT_ALGORITHMS.includes(v);
1065
+ }
1066
+ function keyFits(jwk, alg) {
1067
+ const s = spec(alg);
1068
+ if (jwk.kty !== s.kty)
1069
+ return false;
1070
+ if (s.crv !== undefined && jwk.crv !== s.crv)
1071
+ return false;
1072
+ if (jwk.use !== undefined && jwk.use !== "sig")
1073
+ return false;
1074
+ if (jwk.alg !== undefined && jwk.alg !== alg)
1075
+ return false;
1076
+ return true;
1077
+ }
1078
+ function publicJwk(jwk) {
1079
+ const out = { kty: jwk.kty };
1080
+ for (const k of ["n", "e", "crv", "x", "y"]) {
1081
+ const v = jwk[k];
1082
+ if (typeof v === "string")
1083
+ out[k] = v;
1084
+ }
1085
+ return out;
1086
+ }
1087
+ async function fetchJson(url, init = {}, maxBytes = MAX_JWKS_BYTES) {
1088
+ const res = await fetch(url, {
1089
+ ...init,
1090
+ redirect: "error",
1091
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
1092
+ headers: { accept: "application/json", ...init.headers ?? {} }
1093
+ });
1094
+ const length = Number(res.headers.get("content-length") ?? "0");
1095
+ if (length > maxBytes)
1096
+ throw new Error(`response too large (${url})`);
1097
+ const chunks = [];
1098
+ let size = 0;
1099
+ const reader = res.body?.getReader();
1100
+ while (reader !== undefined) {
1101
+ const { done, value } = await reader.read();
1102
+ if (done)
1103
+ break;
1104
+ size += value.byteLength;
1105
+ if (size > maxBytes) {
1106
+ await reader.cancel();
1107
+ throw new Error(`response too large (${url})`);
1108
+ }
1109
+ chunks.push(value);
1110
+ }
1111
+ const bytes = new Uint8Array(size);
1112
+ let offset = 0;
1113
+ for (const chunk of chunks) {
1114
+ bytes.set(chunk, offset);
1115
+ offset += chunk.byteLength;
1116
+ }
1117
+ const text = new TextDecoder().decode(bytes);
1118
+ let value;
1119
+ try {
1120
+ value = JSON.parse(text);
1121
+ } catch {
1122
+ throw new Error(`not JSON (${url}, HTTP ${res.status})`);
1123
+ }
1124
+ if (!res.ok) {
1125
+ const error = value?.["error"];
1126
+ throw new HttpJsonError(res.status, typeof error === "string" ? error : "", url);
1127
+ }
1128
+ return value;
1129
+ }
1130
+
1131
+ class HttpJsonError extends Error {
1132
+ name = "HttpJsonError";
1133
+ status;
1134
+ error;
1135
+ constructor(status, error, url) {
1136
+ super(`HTTP ${status}${error === "" ? "" : ` ${error}`} (${url})`);
1137
+ this.status = status;
1138
+ this.error = error;
1139
+ }
1140
+ }
1141
+
1142
+ class JwksCache {
1143
+ #url;
1144
+ #now;
1145
+ #keys = [];
1146
+ #fetchedAt = 0;
1147
+ #attemptedAt = Number.NEGATIVE_INFINITY;
1148
+ #lastError;
1149
+ #inflight;
1150
+ constructor(url, options = {}) {
1151
+ this.#url = typeof url === "string" ? () => Promise.resolve(url) : url;
1152
+ this.#now = options.now ?? Date.now;
1153
+ }
1154
+ async keys(kid) {
1155
+ if (this.#inflight !== undefined)
1156
+ await this.#inflight.catch(() => {});
1157
+ const now = this.#now();
1158
+ const stale = now - this.#fetchedAt >= JWKS_TTL_MS;
1159
+ const unknown = kid !== undefined && !this.#keys.some((k) => k.kid === kid);
1160
+ if ((stale || unknown) && now - this.#attemptedAt >= JWKS_REFETCH_MS) {
1161
+ try {
1162
+ await this.#refresh();
1163
+ } catch (error) {
1164
+ this.#lastError = error instanceof Error ? error : new Error("JWKS");
1165
+ }
1166
+ }
1167
+ if (this.#now() - this.#fetchedAt >= JWKS_TTL_MS + JWKS_GRACE_MS) {
1168
+ throw this.#lastError ?? new Error("the JWKS could not be loaded");
1169
+ }
1170
+ return kid === undefined ? this.#keys : this.#keys.filter((k) => k.kid === kid);
1171
+ }
1172
+ #refresh() {
1173
+ this.#inflight ??= (async () => {
1174
+ this.#attemptedAt = this.#now();
1175
+ try {
1176
+ const doc = await fetchJson(await this.#url());
1177
+ const keys = doc?.keys;
1178
+ if (!Array.isArray(keys))
1179
+ throw new Error("a JWKS needs keys");
1180
+ this.#keys = keys.filter((k) => typeof k === "object" && k !== null);
1181
+ this.#fetchedAt = this.#now();
1182
+ this.#lastError = undefined;
1183
+ } finally {
1184
+ this.#inflight = undefined;
1185
+ }
1186
+ })();
1187
+ return this.#inflight;
1188
+ }
1189
+ }
1190
+ async function verifyJwt(token, source, options = {}) {
1191
+ const jwt = decodeJwt(token);
1192
+ const { header } = jwt;
1193
+ const alg = header["alg"];
1194
+ const allowed = options.algorithms ?? JWT_ALGORITHMS;
1195
+ if (!isAlgorithm(alg) || !allowed.includes(alg)) {
1196
+ throw new JwtError("alg", `the token's algorithm is not accepted (${String(alg).slice(0, 20)})`);
1197
+ }
1198
+ if (header["crit"] !== undefined) {
1199
+ throw new JwtError("crit", "the token has critical header parameters");
1200
+ }
1201
+ if (options.typ !== undefined && header["typ"] !== options.typ) {
1202
+ throw new JwtError("typ", `the token's type must be ${options.typ}`);
1203
+ }
1204
+ const kid = header["kid"];
1205
+ if (kid !== undefined && typeof kid !== "string") {
1206
+ throw new JwtError("malformed", "the token's kid is not a string");
1207
+ }
1208
+ let candidates;
1209
+ try {
1210
+ candidates = (await source.keys(kid)).filter((k) => keyFits(k, alg));
1211
+ } catch (error) {
1212
+ throw new JwtError("jwks_unavailable", `the signing keys could not be loaded: ${error instanceof Error ? error.message : "unknown"}`);
1213
+ }
1214
+ if (candidates.length === 0 || kid === undefined && candidates.length > 1) {
1215
+ throw new JwtError("key", "no matching signing key");
1216
+ }
1217
+ const s = spec(alg);
1218
+ for (const jwk of candidates) {
1219
+ let key;
1220
+ try {
1221
+ key = await crypto.subtle.importKey("jwk", publicJwk(jwk), s.importParams, false, ["verify"]);
1222
+ } catch {
1223
+ continue;
1224
+ }
1225
+ const ok = await crypto.subtle.verify(s.verifyParams, key, jwt.signature, jwt.signingInput).catch(() => false);
1226
+ if (ok)
1227
+ return jwt;
1228
+ }
1229
+ throw new JwtError("signature", "the token's signature is not valid");
1230
+ }
1231
+ function numericDate(payload, name) {
1232
+ const v = payload[name];
1233
+ if (v === undefined)
1234
+ return;
1235
+ if (typeof v !== "number" || !Number.isFinite(v)) {
1236
+ throw new JwtError("claims", `${name} must be a number`);
1237
+ }
1238
+ return v * 1000;
1239
+ }
1240
+ function audiences(payload) {
1241
+ const aud = payload["aud"];
1242
+ if (typeof aud === "string")
1243
+ return [aud];
1244
+ if (Array.isArray(aud) && aud.every((a) => typeof a === "string")) {
1245
+ return aud;
1246
+ }
1247
+ throw new JwtError("claims", "aud is missing");
1248
+ }
1249
+
1250
+ // src/identity.ts
1251
+ var TAB_ASSERTION_TYP = "tab-mcp-identity+jwt";
1252
+ var TAB_ASSERTION_MAX_LIFETIME_MS = 60000;
1253
+ var CLOCK_LEEWAY_MS = 5000;
1254
+ var MAX_SUBJECT = 255;
1255
+ var MAX_JTI = 128;
1256
+ var MAX_SEEN_JTI = 1e5;
1257
+ var MAX_JTI_PER_PERSON = 120;
1258
+ var MISMATCH_TEXT = "This AI tool is logged in as someone other than the page.";
1259
+ function identityKey(issuer, subject) {
1260
+ return new Bun.CryptoHasher("sha256").update(`${issuer}
1261
+ ${subject}`).digest().toBase64({ alphabet: "base64url", omitPadding: true });
1262
+ }
1263
+
1264
+ class TabAssertionVerifier {
1265
+ #oidc;
1266
+ #keys;
1267
+ #seen = new Map;
1268
+ #perPerson = new Map;
1269
+ #sweptAt = Number.NEGATIVE_INFINITY;
1270
+ constructor(oidc, keys) {
1271
+ this.#oidc = oidc;
1272
+ this.#keys = keys ?? new JwksCache(oidc.assertionJwksUrl);
1273
+ }
1274
+ async verify(token, address, now = Date.now()) {
1275
+ const { payload } = await verifyJwt(token, this.#keys, {
1276
+ typ: TAB_ASSERTION_TYP
1277
+ });
1278
+ if (payload["iss"] !== this.#oidc.assertionIssuer) {
1279
+ throw new JwtError("iss", "the assertion's issuer is not the configured one");
1280
+ }
1281
+ const aud = audiences(payload);
1282
+ if (aud.length !== 1 || aud[0] !== address) {
1283
+ throw new JwtError("aud", "the assertion is not for this address");
1284
+ }
1285
+ const exp = numericDate(payload, "exp");
1286
+ if (exp === undefined)
1287
+ throw new JwtError("exp", "the assertion has no exp");
1288
+ if (exp <= now - CLOCK_LEEWAY_MS) {
1289
+ throw new JwtError("expired", "the assertion has expired");
1290
+ }
1291
+ if (exp > now + TAB_ASSERTION_MAX_LIFETIME_MS + CLOCK_LEEWAY_MS) {
1292
+ throw new JwtError("lifetime", "the assertion lives longer than 60 seconds");
1293
+ }
1294
+ const iat = numericDate(payload, "iat");
1295
+ if (iat !== undefined && iat > now + CLOCK_LEEWAY_MS) {
1296
+ throw new JwtError("iat", "the assertion was issued in the future");
1297
+ }
1298
+ const nbf = numericDate(payload, "nbf");
1299
+ if (nbf !== undefined && nbf > now + CLOCK_LEEWAY_MS) {
1300
+ throw new JwtError("nbf", "the assertion is not valid yet");
1301
+ }
1302
+ const sub = payload["sub"];
1303
+ if (typeof sub !== "string" || sub === "" || sub.length > MAX_SUBJECT) {
1304
+ throw new JwtError("sub", "the assertion needs a subject");
1305
+ }
1306
+ const jti = payload["jti"];
1307
+ if (typeof jti !== "string" || jti === "" || jti.length > MAX_JTI) {
1308
+ throw new JwtError("jti", "the assertion needs a jti");
1309
+ }
1310
+ this.#sweep(now);
1311
+ const key = identityKey(this.#oidc.issuer, sub);
1312
+ const seenKey = `${key}
1313
+ ${jti}`;
1314
+ if (this.#seen.has(seenKey)) {
1315
+ throw new JwtError("replayed", "the assertion was already used");
1316
+ }
1317
+ const mine = this.#perPerson.get(key) ?? 0;
1318
+ if (mine >= MAX_JTI_PER_PERSON || this.#seen.size >= MAX_SEEN_JTI) {
1319
+ throw new JwtError("busy", "too many assertions at once");
1320
+ }
1321
+ this.#seen.set(seenKey, exp + CLOCK_LEEWAY_MS);
1322
+ this.#perPerson.set(key, mine + 1);
1323
+ return key;
1324
+ }
1325
+ #sweep(now) {
1326
+ if (now - this.#sweptAt < 1000 && this.#seen.size < MAX_SEEN_JTI)
1327
+ return;
1328
+ this.#sweptAt = now;
1329
+ for (const [seenKey, until] of this.#seen) {
1330
+ if (until > now)
1331
+ continue;
1332
+ this.#seen.delete(seenKey);
1333
+ const person = seenKey.slice(0, seenKey.indexOf(`
1334
+ `));
1335
+ const left = (this.#perPerson.get(person) ?? 1) - 1;
1336
+ if (left > 0)
1337
+ this.#perPerson.set(person, left);
1338
+ else
1339
+ this.#perPerson.delete(person);
1340
+ }
1341
+ }
1342
+ }
1343
+
887
1344
  // src/protocol.ts
888
1345
  var SUBPROTOCOL = "tab-mcp.v1";
889
1346
  var MCP_PROTOCOL_VERSION = "2026-07-28";
@@ -1024,7 +1481,19 @@ function parseTabMessage(text) {
1024
1481
  case "pairing.start":
1025
1482
  case "pairing.stop":
1026
1483
  case "grants.revokeAll":
1484
+ case "identity.logout":
1027
1485
  return { ok: true, message: { type } };
1486
+ case "identity": {
1487
+ const assertion = v["assertion"];
1488
+ if (typeof assertion !== "string" || assertion === "" || assertion.length > MAX_JWT_BYTES) {
1489
+ return bad(`identity.assertion must be a JWT of at most ${MAX_JWT_BYTES} bytes`);
1490
+ }
1491
+ const round = v["round"];
1492
+ if (round !== undefined && !(Number.isSafeInteger(round) && round >= 0)) {
1493
+ return bad("identity.round must be a non-negative integer");
1494
+ }
1495
+ return round === undefined ? { ok: true, message: { type, assertion } } : { ok: true, message: { type, assertion, round } };
1496
+ }
1028
1497
  case "grant.revoke": {
1029
1498
  const grantId = v["grantId"];
1030
1499
  if (!isId(grantId))
@@ -1061,6 +1530,12 @@ function pageName(appName) {
1061
1530
  function notOpenText(appName) {
1062
1531
  return `The page is not open, or not in front. Ask the user to open ${pageName(appName)} and keep it in front (on a phone, in split screen), then try again.`;
1063
1532
  }
1533
+ function signedOutText(appName) {
1534
+ return `The page is open, but nobody is signed in to it. Ask the user to sign in to ${pageName(appName)}, then try again.`;
1535
+ }
1536
+ function phaseText(phase, appName) {
1537
+ return phase === "signed_out" ? signedOutText(appName) : notOpenText(appName);
1538
+ }
1064
1539
  function goneDuringCallText(appName) {
1065
1540
  return `The page closed while the tool was running, so it may or may not have finished. Ask the user to open ${pageName(appName)} and keep it in front (on a phone, in split screen), then check the result before trying again.`;
1066
1541
  }
@@ -1070,7 +1545,7 @@ function notOpenAnswer(body, rpcId, appName, phase, relay) {
1070
1545
  const id = rpcId;
1071
1546
  const method = body["method"];
1072
1547
  if (method === "tools/call") {
1073
- const text = phase === "gone" ? goneDuringCallText(appName) : notOpenText(appName);
1548
+ const text = phase === "gone" ? goneDuringCallText(appName) : phaseText(phase, appName);
1074
1549
  return JSON.stringify({
1075
1550
  jsonrpc: "2.0",
1076
1551
  id,
@@ -1089,7 +1564,7 @@ function notOpenAnswer(body, rpcId, appName, phase, relay) {
1089
1564
  resultType: "complete",
1090
1565
  supportedVersions: [MCP_PROTOCOL_VERSION],
1091
1566
  capabilities: { tools: { listChanged: true } },
1092
- instructions: notOpenText(appName),
1567
+ instructions: phaseText(phase, appName),
1093
1568
  ttlMs: 0,
1094
1569
  cacheScope: "private",
1095
1570
  _meta: {
@@ -1106,7 +1581,7 @@ function notOpenAnswer(body, rpcId, appName, phase, relay) {
1106
1581
  id,
1107
1582
  error: {
1108
1583
  code: phase === "gone" ? RelayErrorCode.tabGone : RelayErrorCode.pageNotOpen,
1109
- message: notOpenText(appName)
1584
+ message: phaseText(phase, appName)
1110
1585
  }
1111
1586
  });
1112
1587
  }
@@ -1183,7 +1658,8 @@ async function issueTokenPair(store, grant, options) {
1183
1658
  clientName: grant.clientName,
1184
1659
  scopes: grant.scopes,
1185
1660
  grantCreatedAt: grant.grantCreatedAt,
1186
- issuedAt: now
1661
+ issuedAt: now,
1662
+ ...grant.subject === undefined ? {} : { subject: grant.subject }
1187
1663
  };
1188
1664
  const accessExpires = Math.min(now + options.accessTtlMs, options.grantEndsAt);
1189
1665
  const refreshStored = await store.put({
@@ -1574,6 +2050,348 @@ ${body}
1574
2050
  </html>`;
1575
2051
  }
1576
2052
 
2053
+ // src/oauth/oidc.ts
2054
+ var FLOW_TTL_MS = 10 * 60000;
2055
+ var MAX_FLOWS = 1e4;
2056
+ var METADATA_TTL_MS = 60 * 60000;
2057
+ var MAX_TOKEN_RESPONSE_BYTES = 65536;
2058
+
2059
+ class OidcError extends Error {
2060
+ name = "OidcError";
2061
+ reason;
2062
+ constructor(reason, message) {
2063
+ super(message);
2064
+ this.reason = reason;
2065
+ }
2066
+ }
2067
+ function random2(bytes) {
2068
+ return crypto.getRandomValues(new Uint8Array(bytes)).toBase64({ alphabet: "base64url", omitPadding: true });
2069
+ }
2070
+ function s256(verifier) {
2071
+ return new Bun.CryptoHasher("sha256").update(verifier).digest().toBase64({ alphabet: "base64url", omitPadding: true });
2072
+ }
2073
+ function str(v) {
2074
+ return typeof v === "string" && v !== "" ? v : undefined;
2075
+ }
2076
+ function originOf(url) {
2077
+ try {
2078
+ return new URL(url).origin;
2079
+ } catch {
2080
+ return;
2081
+ }
2082
+ }
2083
+
2084
+ class ProviderDiscovery {
2085
+ #issuer;
2086
+ #development;
2087
+ #metadata;
2088
+ #fetchedAt = 0;
2089
+ #inflight;
2090
+ constructor(issuer, development) {
2091
+ this.#issuer = issuer;
2092
+ this.#development = development;
2093
+ }
2094
+ get cached() {
2095
+ return this.#metadata;
2096
+ }
2097
+ async get() {
2098
+ if (this.#metadata !== undefined && Date.now() - this.#fetchedAt < METADATA_TTL_MS) {
2099
+ return this.#metadata;
2100
+ }
2101
+ this.#inflight ??= this.#load().finally(() => {
2102
+ this.#inflight = undefined;
2103
+ });
2104
+ return this.#inflight;
2105
+ }
2106
+ async#load() {
2107
+ const url = `${this.#issuer.replace(/\/+$/, "")}/.well-known/openid-configuration`;
2108
+ const doc = await fetchJson(url);
2109
+ if (typeof doc !== "object" || doc === null) {
2110
+ throw new OidcError("discovery", "the provider's metadata is not an object");
2111
+ }
2112
+ if (doc["issuer"] !== this.#issuer) {
2113
+ throw new OidcError("discovery_issuer", "the provider's metadata names another issuer");
2114
+ }
2115
+ const endpoint = (name) => {
2116
+ const v = str(doc[name]);
2117
+ const ok = v !== undefined && (v.startsWith("https://") || this.#development && v.startsWith("http://"));
2118
+ if (!ok) {
2119
+ throw new OidcError("discovery", `the provider's metadata lacks ${name}`);
2120
+ }
2121
+ return v;
2122
+ };
2123
+ const methods = doc["token_endpoint_auth_methods_supported"];
2124
+ const tokenAuth = Array.isArray(methods) && !methods.includes("client_secret_basic") && methods.includes("client_secret_post") ? "client_secret_post" : "client_secret_basic";
2125
+ const metadata = {
2126
+ issuer: this.#issuer,
2127
+ authorizationEndpoint: endpoint("authorization_endpoint"),
2128
+ tokenEndpoint: endpoint("token_endpoint"),
2129
+ jwksUri: endpoint("jwks_uri"),
2130
+ tokenAuth
2131
+ };
2132
+ this.#metadata = metadata;
2133
+ this.#fetchedAt = Date.now();
2134
+ return metadata;
2135
+ }
2136
+ }
2137
+
2138
+ class OidcPairing {
2139
+ kind = "oidc";
2140
+ #config;
2141
+ #publicUrl;
2142
+ #logger;
2143
+ #discovery;
2144
+ #jwks;
2145
+ #flows = new Map;
2146
+ #mismatched = new Set;
2147
+ constructor(options) {
2148
+ this.#config = options.config;
2149
+ this.#publicUrl = options.publicUrl;
2150
+ this.#logger = options.logger;
2151
+ this.#discovery = new ProviderDiscovery(options.config.issuer, options.development);
2152
+ this.#jwks = new JwksCache(async () => (await this.#discovery.get()).jwksUri);
2153
+ }
2154
+ get #providerHost() {
2155
+ try {
2156
+ return new URL(this.#config.issuer).host;
2157
+ } catch {
2158
+ return this.#config.issuer;
2159
+ }
2160
+ }
2161
+ get callbackUrl() {
2162
+ return `${this.#publicUrl()}/authorize/callback`;
2163
+ }
2164
+ async prepare() {
2165
+ await this.#discovery.get();
2166
+ }
2167
+ formTargets() {
2168
+ const out = new Set;
2169
+ const issuer = originOf(this.#config.issuer);
2170
+ if (issuer !== undefined)
2171
+ out.add(issuer);
2172
+ const auth = this.#discovery.cached?.authorizationEndpoint;
2173
+ const origin = auth === undefined ? undefined : originOf(auth);
2174
+ if (origin !== undefined)
2175
+ out.add(origin);
2176
+ return [...out];
2177
+ }
2178
+ render(target) {
2179
+ const host = escapeHtml(this.#providerHost);
2180
+ if (target.identity === undefined) {
2181
+ return `<p class="help">Nobody is signed in to the page. Sign in there first, then click Connect.</p>`;
2182
+ }
2183
+ return `<p>When you click Connect, sign in at <strong>${host}</strong> with the account you use in the page. No code is needed.</p>`;
2184
+ }
2185
+ async check(target, _form, _admit, context) {
2186
+ if (!target.pairingOpen) {
2187
+ return {
2188
+ ok: false,
2189
+ message: "The page is not connecting right now. In the page, open \u201CConnect your AI app\u201D, then click Connect here again.",
2190
+ reason: "inactive"
2191
+ };
2192
+ }
2193
+ if (target.identity === undefined) {
2194
+ return {
2195
+ ok: false,
2196
+ message: "Nobody is signed in to the page. Sign in there first, then click Connect here again.",
2197
+ reason: "signed_out"
2198
+ };
2199
+ }
2200
+ if (context === undefined) {
2201
+ return { ok: false, message: "Something went wrong.", reason: "context" };
2202
+ }
2203
+ let metadata;
2204
+ try {
2205
+ metadata = await this.#discovery.get();
2206
+ } catch (error) {
2207
+ return this.#unavailable(error);
2208
+ }
2209
+ this.#sweep();
2210
+ if (this.#flows.size >= MAX_FLOWS) {
2211
+ return {
2212
+ ok: false,
2213
+ message: "Too many sign-ins are open right now. Wait a few minutes and try again.",
2214
+ reason: "busy"
2215
+ };
2216
+ }
2217
+ const state = random2(24);
2218
+ const nonce = random2(24);
2219
+ const verifier = random2(32);
2220
+ this.#flows.set(state, {
2221
+ requestId: context.requestId,
2222
+ browserId: context.browserId,
2223
+ nonce,
2224
+ verifier,
2225
+ expiresAt: Date.now() + FLOW_TTL_MS
2226
+ });
2227
+ const url = new URL(metadata.authorizationEndpoint);
2228
+ const params = {
2229
+ response_type: "code",
2230
+ client_id: this.#config.clientId,
2231
+ redirect_uri: this.callbackUrl,
2232
+ scope: "openid",
2233
+ state,
2234
+ nonce,
2235
+ code_challenge: s256(verifier),
2236
+ code_challenge_method: "S256"
2237
+ };
2238
+ if (this.#mismatched.has(context.requestId))
2239
+ params["prompt"] = "login";
2240
+ for (const [k, v] of Object.entries(params))
2241
+ url.searchParams.set(k, v);
2242
+ return { ok: "redirect", location: url.href };
2243
+ }
2244
+ async callback(params, browserId, targetFor) {
2245
+ this.#sweep();
2246
+ const state = params.get("state") ?? "";
2247
+ const flow = this.#flows.get(state);
2248
+ if (flow === undefined || flow.expiresAt <= Date.now()) {
2249
+ return {
2250
+ result: {
2251
+ ok: false,
2252
+ message: "This sign-in has expired or was already used. Start again from your AI app.",
2253
+ reason: "unknown_state"
2254
+ }
2255
+ };
2256
+ }
2257
+ this.#flows.delete(state);
2258
+ const requestId = flow.requestId;
2259
+ const fail = (reason, message) => ({
2260
+ requestId,
2261
+ result: { ok: false, message, reason }
2262
+ });
2263
+ if (flow.browserId !== browserId) {
2264
+ return fail("browser", "This sign-in was started in another browser. Start again from your AI app.");
2265
+ }
2266
+ const iss = params.get("iss");
2267
+ if (iss !== null && iss !== this.#config.issuer) {
2268
+ return fail("iss", "The sign-in came back from an unexpected provider.");
2269
+ }
2270
+ const error = params.get("error");
2271
+ if (error !== null) {
2272
+ this.#logger.info("oidc.provider_error", { error: error.slice(0, 64) });
2273
+ return fail(`provider_${error.slice(0, 32)}`, `The sign-in at ${this.#providerHost} did not finish. Click Connect to try again.`);
2274
+ }
2275
+ const code = params.get("code");
2276
+ if (code === null || code === "") {
2277
+ return fail("no_code", "The provider sent no sign-in result. Click Connect to try again.");
2278
+ }
2279
+ let subject;
2280
+ try {
2281
+ subject = await this.#signIn(code, flow);
2282
+ } catch (e) {
2283
+ const reason = e instanceof OidcError || e instanceof JwtError ? e.reason : "exchange";
2284
+ this.#logger.warn("oidc.sign_in_failed", {
2285
+ reason,
2286
+ message: e instanceof Error ? e.message.slice(0, 200) : "unknown"
2287
+ });
2288
+ return fail(reason, `The sign-in at ${this.#providerHost} could not be checked. Click Connect to try again.`);
2289
+ }
2290
+ const target = targetFor(requestId);
2291
+ if (target === undefined) {
2292
+ return fail("page_closed", "The page is not open any more. Open it and try again.");
2293
+ }
2294
+ if (target.identity === undefined) {
2295
+ return fail("signed_out", "Nobody is signed in to the page any more. Sign in there first, then click Connect here again.");
2296
+ }
2297
+ const key = identityKey(this.#config.issuer, subject);
2298
+ if (key !== target.identity) {
2299
+ this.#mismatched.add(requestId);
2300
+ if (this.#mismatched.size > MAX_FLOWS) {
2301
+ const oldest = this.#mismatched.values().next().value;
2302
+ if (oldest !== undefined)
2303
+ this.#mismatched.delete(oldest);
2304
+ }
2305
+ return fail("mismatch", `${MISMATCH_TEXT} Click Connect to sign in with the page's account, or Cancel.`);
2306
+ }
2307
+ this.#mismatched.delete(requestId);
2308
+ return { requestId, result: { ok: true, subject: key } };
2309
+ }
2310
+ async#signIn(code, flow) {
2311
+ const metadata = await this.#discovery.get();
2312
+ const body = new URLSearchParams({
2313
+ grant_type: "authorization_code",
2314
+ code,
2315
+ redirect_uri: this.callbackUrl,
2316
+ code_verifier: flow.verifier
2317
+ });
2318
+ const headers = {
2319
+ "content-type": "application/x-www-form-urlencoded"
2320
+ };
2321
+ const secret = this.#config.clientSecret;
2322
+ if (secret !== undefined && metadata.tokenAuth === "client_secret_basic") {
2323
+ const enc = (v) => encodeURIComponent(v).replace(/%20/g, "+");
2324
+ headers["authorization"] = `Basic ${btoa(`${enc(this.#config.clientId)}:${enc(secret)}`)}`;
2325
+ } else {
2326
+ body.set("client_id", this.#config.clientId);
2327
+ if (secret !== undefined)
2328
+ body.set("client_secret", secret);
2329
+ }
2330
+ let response;
2331
+ try {
2332
+ response = await fetchJson(metadata.tokenEndpoint, { method: "POST", headers, body }, MAX_TOKEN_RESPONSE_BYTES);
2333
+ } catch (e) {
2334
+ if (e instanceof HttpJsonError) {
2335
+ throw new OidcError(`token_${e.error || e.status}`, e.message);
2336
+ }
2337
+ throw e;
2338
+ }
2339
+ const idToken = str(response?.["id_token"]);
2340
+ if (idToken === undefined) {
2341
+ throw new OidcError("no_id_token", "the token response has no id_token");
2342
+ }
2343
+ const { payload } = await verifyJwt(idToken, this.#jwks);
2344
+ const now = Date.now();
2345
+ if (payload["iss"] !== metadata.issuer) {
2346
+ throw new OidcError("id_token_iss", "the ID token's issuer is wrong");
2347
+ }
2348
+ const aud = audiences(payload);
2349
+ if (!aud.includes(this.#config.clientId)) {
2350
+ throw new OidcError("id_token_aud", "the ID token is not for this relay");
2351
+ }
2352
+ const azp = payload["azp"];
2353
+ if ((aud.length > 1 || azp !== undefined) && azp !== this.#config.clientId) {
2354
+ throw new OidcError("id_token_azp", "the ID token's azp is not this relay");
2355
+ }
2356
+ const exp = numericDate(payload, "exp");
2357
+ if (exp === undefined || exp <= now - CLOCK_LEEWAY_MS) {
2358
+ throw new OidcError("id_token_exp", "the ID token has expired");
2359
+ }
2360
+ const iat = numericDate(payload, "iat");
2361
+ if (iat === undefined || iat > now + CLOCK_LEEWAY_MS) {
2362
+ throw new OidcError("id_token_iat", "the ID token's iat is missing or in the future");
2363
+ }
2364
+ if (payload["nonce"] !== flow.nonce) {
2365
+ throw new OidcError("id_token_nonce", "the ID token's nonce does not match");
2366
+ }
2367
+ const claim = this.#config.subjectClaim ?? "sub";
2368
+ const subject = payload[claim];
2369
+ if (typeof subject !== "string" || subject === "" || subject.length > 255) {
2370
+ throw new OidcError("id_token_subject", `the ID token has no ${claim}`);
2371
+ }
2372
+ return subject;
2373
+ }
2374
+ #unavailable(error) {
2375
+ this.#logger.warn("oidc.discovery_failed", {
2376
+ reason: error instanceof OidcError ? error.reason : "fetch",
2377
+ message: error instanceof Error ? error.message.slice(0, 200) : "unknown"
2378
+ });
2379
+ return {
2380
+ ok: false,
2381
+ message: `The sign-in provider ${this.#providerHost} cannot be reached right now. Try again in a minute.`,
2382
+ reason: "provider_unavailable"
2383
+ };
2384
+ }
2385
+ #sweep() {
2386
+ const now = Date.now();
2387
+ for (const [state, flow] of this.#flows) {
2388
+ if (flow.expiresAt > now)
2389
+ break;
2390
+ this.#flows.delete(state);
2391
+ }
2392
+ }
2393
+ }
2394
+
1577
2395
  // src/oauth/pairing.ts
1578
2396
  import { timingSafeEqual } from "crypto";
1579
2397
  var PAIRING_CODE_TTL_MS = 120000;
@@ -1725,7 +2543,7 @@ function constantTimeEqual(a, b) {
1725
2543
  const y = Buffer.from(b);
1726
2544
  return x.length === y.length && timingSafeEqual2(x, y);
1727
2545
  }
1728
- function s256(verifier) {
2546
+ function s2562(verifier) {
1729
2547
  return new Bun.CryptoHasher("sha256").update(verifier).digest().toBase64({ alphabet: "base64url", omitPadding: true });
1730
2548
  }
1731
2549
  var VERIFIER = /^[A-Za-z0-9\-._~]{43,128}$/;
@@ -1740,7 +2558,7 @@ function single(params, names) {
1740
2558
  }
1741
2559
  return out;
1742
2560
  }
1743
- function random2(bytes) {
2561
+ function random3(bytes) {
1744
2562
  return crypto.getRandomValues(new Uint8Array(bytes)).toBase64({ alphabet: "base64url", omitPadding: true });
1745
2563
  }
1746
2564
  function formTarget(redirectUri) {
@@ -1769,7 +2587,7 @@ class AuthorizationServer {
1769
2587
  #codes = new Map;
1770
2588
  #authCodeTtlMs;
1771
2589
  #methods;
1772
- #csrfSecret = random2(32);
2590
+ #csrfSecret = random3(32);
1773
2591
  #viewsPerIp = new RateLimiter({
1774
2592
  capacity: 30,
1775
2593
  refillPerSecond: 0.5
@@ -1790,7 +2608,19 @@ class AuthorizationServer {
1790
2608
  constructor(host, testMode) {
1791
2609
  this.#host = host;
1792
2610
  this.#authCodeTtlMs = testMode?.authCodeTtlMs ?? AUTH_CODE_TTL_MS;
1793
- this.#methods = testMode?.pairingMethods ?? PAIRING_METHODS;
2611
+ const oidc = host.config.oidc;
2612
+ this.#methods = {
2613
+ ...PAIRING_METHODS,
2614
+ ...oidc === undefined ? {} : {
2615
+ oidc: new OidcPairing({
2616
+ config: oidc,
2617
+ publicUrl: () => host.publicUrl(),
2618
+ logger: host.logger,
2619
+ development: host.config.development
2620
+ })
2621
+ },
2622
+ ...testMode?.pairingMethods
2623
+ };
1794
2624
  this.#cimd = new ClientMetadataFetcher({
1795
2625
  allowedHosts: host.config.clientIdHosts,
1796
2626
  logger: host.logger,
@@ -2001,12 +2831,23 @@ class AuthorizationServer {
2001
2831
  if (this.#host.session(addressId) === undefined) {
2002
2832
  return this.#offlinePage(client, `/authorize${new URL(req.url).search}`, this.#errorUrl(redirectUri, state, "access_denied", "the user cancelled the connection"));
2003
2833
  }
2834
+ const method = this.#methods[this.#host.session(addressId)?.site.pairing ?? "code"];
2835
+ if (method?.prepare !== undefined) {
2836
+ try {
2837
+ await method.prepare();
2838
+ } catch (error) {
2839
+ this.#host.logger.warn("oidc.discovery_failed", {
2840
+ message: error instanceof Error ? error.message.slice(0, 200) : "unknown"
2841
+ });
2842
+ return this.#errorPage(503, "The sign-in provider of this page cannot be reached right now. Wait a minute and try again.");
2843
+ }
2844
+ }
2004
2845
  this.#sweepPending();
2005
2846
  if (this.#pending.size >= MAX_PENDING || (this.#pendingPerIp.get(ip) ?? 0) >= MAX_PENDING_PER_IP || (this.#pendingPerAddress.get(addressId) ?? 0) >= MAX_PENDING_PER_ADDRESS) {
2006
2847
  return this.#errorPage(429, "Too many connection requests are open right now. Wait a few minutes and try again.");
2007
2848
  }
2008
2849
  const pending = {
2009
- id: random2(24),
2850
+ id: random3(24),
2010
2851
  expiresAt: Date.now() + PENDING_TTL_MS,
2011
2852
  ip,
2012
2853
  client,
@@ -2103,7 +2944,25 @@ ${errorHtml}
2103
2944
  return this.#errorPage(500, "This page's pairing method is not available.");
2104
2945
  }
2105
2946
  const admit = () => this.#guessesPerAddressIp.take(`${pending.addressId}|${ip}`) && this.#guessesPerAddress.take(pending.addressId);
2106
- const result = await method.check(session, form, admit);
2947
+ const result = await method.check(session, form, admit, {
2948
+ requestId: pending.id,
2949
+ browserId: browser.id
2950
+ });
2951
+ if (result.ok === "redirect") {
2952
+ this.#host.logger.info("oauth.pairing_redirect", {
2953
+ address: shortAddress(pending.addressId),
2954
+ client: pending.client.host,
2955
+ method: method.kind
2956
+ });
2957
+ return new Response(null, {
2958
+ status: 303,
2959
+ headers: {
2960
+ location: result.location,
2961
+ "cache-control": "no-store",
2962
+ "referrer-policy": "no-referrer"
2963
+ }
2964
+ });
2965
+ }
2107
2966
  if (!result.ok) {
2108
2967
  this.#host.logger.info("oauth.pairing_failed", {
2109
2968
  address: shortAddress(pending.addressId),
@@ -2112,20 +2971,24 @@ ${errorHtml}
2112
2971
  });
2113
2972
  return this.#pairingPage(pending, browser.id, result.message, result.reason === "rate_limited" ? 429 : 400);
2114
2973
  }
2974
+ return this.#paired(pending, method.kind, result);
2975
+ }
2976
+ #paired(pending, kind, result) {
2115
2977
  this.#dropPending(pending);
2116
- const code = random2(32);
2978
+ const code = random3(32);
2117
2979
  if (this.#codes.size >= MAX_CODES)
2118
2980
  this.#sweepCodes(true);
2119
2981
  const { id: _id, expiresAt: _expires, ip: _ip, ...request } = pending;
2120
2982
  this.#codes.set(hashToken(code), {
2121
2983
  ...request,
2122
2984
  expiresAt: Date.now() + this.#authCodeTtlMs,
2123
- used: false
2985
+ used: false,
2986
+ ...result.subject === undefined ? {} : { subject: result.subject }
2124
2987
  });
2125
2988
  this.#host.logger.info("oauth.paired", {
2126
2989
  address: shortAddress(pending.addressId),
2127
2990
  client: pending.client.host,
2128
- method: method.kind
2991
+ method: kind
2129
2992
  });
2130
2993
  return this.#redirect(pending.redirectUri, {
2131
2994
  code,
@@ -2133,6 +2996,40 @@ ${errorHtml}
2133
2996
  iss: this.#issuer
2134
2997
  });
2135
2998
  }
2999
+ async authorizeCallback(req, ip) {
3000
+ if (!this.#viewsPerIp.take(ip)) {
3001
+ return this.#errorPage(429, "Too many requests from your network. Wait a minute and try again.");
3002
+ }
3003
+ const method = this.#methods.oidc;
3004
+ if (method?.callback === undefined) {
3005
+ return new Response("Not found", { status: 404 });
3006
+ }
3007
+ const browser = this.#browserId(req);
3008
+ if (browser.cookie !== undefined) {
3009
+ return this.#errorPage(403, "The sign-in could not be checked. Your browser may block cookies for this site; start again from your AI app.");
3010
+ }
3011
+ this.#sweepPending();
3012
+ const params = new URL(req.url).searchParams;
3013
+ const { requestId, result } = await method.callback(params, browser.id, (id) => {
3014
+ const p = this.#pending.get(id);
3015
+ return p === undefined ? undefined : this.#host.session(p.addressId);
3016
+ });
3017
+ const pending = requestId === undefined ? undefined : this.#pending.get(requestId);
3018
+ if (pending === undefined || pending.expiresAt <= Date.now()) {
3019
+ const message = result.ok === false ? result.message : "This connection request has expired or was already used.";
3020
+ return this.#errorPage(400, message);
3021
+ }
3022
+ if (result.ok === true)
3023
+ return this.#paired(pending, method.kind, result);
3024
+ const message = result.ok === false ? result.message : "Something went wrong.";
3025
+ const reason = result.ok === false ? result.reason : "unexpected";
3026
+ this.#host.logger.info("oauth.pairing_failed", {
3027
+ address: shortAddress(pending.addressId),
3028
+ client: pending.client.host,
3029
+ reason
3030
+ });
3031
+ return this.#pairingPage(pending, browser.id, message, reason === "mismatch" ? 403 : 400);
3032
+ }
2136
3033
  #addressOf(resource) {
2137
3034
  if (resource === undefined)
2138
3035
  return;
@@ -2160,7 +3057,7 @@ ${errorHtml}
2160
3057
  return { id: v };
2161
3058
  }
2162
3059
  }
2163
- const id = random2(16);
3060
+ const id = random3(16);
2164
3061
  const secure = this.#issuer.startsWith("https:") ? "; Secure" : "";
2165
3062
  return {
2166
3063
  id,
@@ -2211,6 +3108,7 @@ ${cancel}
2211
3108
  if (method === undefined) {
2212
3109
  return this.#errorPage(500, "This page's pairing method is not available.");
2213
3110
  }
3111
+ targets.push(...method.formTargets?.() ?? []);
2214
3112
  const page = session.origin === "" ? "a page that did not say where it runs" : `<span class="origin">${escapeHtml(session.origin)}</span>`;
2215
3113
  const target = session.appName === undefined ? `tools from ${page}` : `<strong>${escapeHtml(session.appName)}</strong> on ${page}`;
2216
3114
  const heading = `Connect ${client.name} from ${client.host}`;
@@ -2333,9 +3231,12 @@ ${cancel}
2333
3231
  if (p["resource"] !== undefined && p["resource"] !== issued.resource) {
2334
3232
  throw new OAuthError(400, "invalid_target", "resource does not match the authorization request");
2335
3233
  }
2336
- if (!VERIFIER.test(verifier) || !constantTimeEqual(s256(verifier), issued.codeChallenge)) {
3234
+ if (!VERIFIER.test(verifier) || !constantTimeEqual(s2562(verifier), issued.codeChallenge)) {
2337
3235
  throw invalid("code_verifier does not match the code_challenge");
2338
3236
  }
3237
+ if (issued.subject !== undefined && this.#host.session(issued.addressId)?.identity !== issued.subject) {
3238
+ throw invalid("the page's user signed out or changed");
3239
+ }
2339
3240
  const now = Date.now();
2340
3241
  const grantId = newId("g");
2341
3242
  issued.grantId = grantId;
@@ -2346,7 +3247,8 @@ ${cancel}
2346
3247
  clientId: issued.client.clientId,
2347
3248
  clientName: issued.client.name,
2348
3249
  scopes: issued.scopes,
2349
- grantCreatedAt: now
3250
+ grantCreatedAt: now,
3251
+ ...issued.subject === undefined ? {} : { subject: issued.subject }
2350
3252
  }, {
2351
3253
  accessTtlMs: config.accessTokenTtlMs,
2352
3254
  refreshTtlMs: config.refreshTokenTtlMs,
@@ -2421,7 +3323,8 @@ ${cancel}
2421
3323
  clientId: record.clientId,
2422
3324
  clientName: record.clientName,
2423
3325
  scopes: record.scopes,
2424
- grantCreatedAt: record.grantCreatedAt
3326
+ grantCreatedAt: record.grantCreatedAt,
3327
+ ...record.subject === undefined ? {} : { subject: record.subject }
2425
3328
  }, {
2426
3329
  accessTtlMs: this.#host.config.accessTokenTtlMs,
2427
3330
  refreshTtlMs: this.#host.config.refreshTokenTtlMs,
@@ -2557,10 +3460,13 @@ class TabSession {
2557
3460
  addressId;
2558
3461
  appName;
2559
3462
  codes;
3463
+ identity;
2560
3464
  pending = new Map;
2561
3465
  #ws;
2562
3466
  #host;
2563
3467
  #state = "new";
3468
+ #dialogOpen = false;
3469
+ #identityChain = Promise.resolve();
2564
3470
  #lastRequestAt = Date.now();
2565
3471
  #missedPongs = 0;
2566
3472
  #nextRequest = 0;
@@ -2589,6 +3495,9 @@ class TabSession {
2589
3495
  get ready() {
2590
3496
  return this.#state === "ready";
2591
3497
  }
3498
+ get pairingOpen() {
3499
+ return this.site.pairing === "code" ? this.codes.active : this.#dialogOpen;
3500
+ }
2592
3501
  get limits() {
2593
3502
  return this.site.limits;
2594
3503
  }
@@ -2677,9 +3586,18 @@ class TabSession {
2677
3586
  case "pairing.start":
2678
3587
  if (this.site.pairing === "code")
2679
3588
  this.codes.start();
3589
+ else
3590
+ this.#dialogOpen = true;
2680
3591
  return;
2681
3592
  case "pairing.stop":
2682
3593
  this.codes.stop();
3594
+ this.#dialogOpen = false;
3595
+ return;
3596
+ case "identity":
3597
+ await this.#serialIdentity(() => this.#host.bindIdentity(this, msg.assertion, msg.round));
3598
+ return;
3599
+ case "identity.logout":
3600
+ await this.#serialIdentity(() => this.#host.signOut(this));
2683
3601
  return;
2684
3602
  case "grant.revoke":
2685
3603
  if (!await this.#host.revokeGrant(this, msg.grantId)) {
@@ -2759,6 +3677,7 @@ class TabSession {
2759
3677
  address: `${this.#host.publicUrl()}/a/${this.addressId}/mcp`,
2760
3678
  origin: this.origin,
2761
3679
  ...this.appName === undefined ? {} : { appName: this.appName },
3680
+ pairing: this.site.pairing,
2762
3681
  expiresAt: new Date(expiresAt).toISOString(),
2763
3682
  limits: limitsOf(limits),
2764
3683
  grants
@@ -2778,6 +3697,11 @@ class TabSession {
2778
3697
  });
2779
3698
  this.#host.ready?.(this);
2780
3699
  }
3700
+ #serialIdentity(run) {
3701
+ const next = this.#identityChain.then(run);
3702
+ this.#identityChain = next.catch(() => {});
3703
+ return next;
3704
+ }
2781
3705
  #tick() {
2782
3706
  if (this.#state !== "ready")
2783
3707
  return;
@@ -2852,6 +3776,7 @@ class TabSession {
2852
3776
  const wasReady = this.#state === "ready" || this.#state === "hello";
2853
3777
  this.#state = "ended";
2854
3778
  this.codes.stop();
3779
+ this.#dialogOpen = false;
2855
3780
  clearTimeout(this.#helloTimer);
2856
3781
  clearInterval(this.#heartbeatTimer);
2857
3782
  clearTimeout(this.#lifetimeTimer);
@@ -2891,13 +3816,16 @@ function summarizeGrants(records, now) {
2891
3816
  for (const r of records) {
2892
3817
  if (!isLive(r, now) || grants.has(r.grantId))
2893
3818
  continue;
2894
- grants.set(r.grantId, {
3819
+ const summary = {
2895
3820
  grantId: r.grantId,
2896
3821
  clientId: r.clientId,
2897
3822
  clientName: r.clientName,
2898
3823
  scopes: [...r.scopes],
2899
3824
  createdAt: r.grantCreatedAt
2900
- });
3825
+ };
3826
+ if (r.subject !== undefined)
3827
+ summary.subject = r.subject;
3828
+ grants.set(r.grantId, summary);
2901
3829
  }
2902
3830
  return [...grants.values()].sort((a, b) => a.createdAt - b.createdAt || a.grantId.localeCompare(b.grantId));
2903
3831
  }
@@ -2977,7 +3905,7 @@ class MemoryTokenStore {
2977
3905
 
2978
3906
  // src/store/sqlite.ts
2979
3907
  import { Database } from "bun:sqlite";
2980
- var SCHEMA_VERSION = 2;
3908
+ var SCHEMA_VERSION = 3;
2981
3909
  function toRecord(row) {
2982
3910
  const record = {
2983
3911
  hash: row.hash,
@@ -2993,6 +3921,8 @@ function toRecord(row) {
2993
3921
  };
2994
3922
  if (row.used_at !== null)
2995
3923
  record.usedAt = row.used_at;
3924
+ if (row.subject !== null)
3925
+ record.subject = row.subject;
2996
3926
  return record;
2997
3927
  }
2998
3928
 
@@ -3028,8 +3958,13 @@ class SqliteTokenStore {
3028
3958
  grant_created_at INTEGER NOT NULL,
3029
3959
  issued_at INTEGER NOT NULL,
3030
3960
  expires_at INTEGER NOT NULL,
3031
- used_at INTEGER
3961
+ used_at INTEGER,
3962
+ subject TEXT
3032
3963
  ) STRICT`);
3964
+ const columns = this.#db.query("PRAGMA table_info(tokens)").all();
3965
+ if (!columns.some((c) => c.name === "subject")) {
3966
+ this.#db.run("ALTER TABLE tokens ADD COLUMN subject TEXT");
3967
+ }
3033
3968
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_grant ON tokens (grant_id)");
3034
3969
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_address ON tokens (address_id)");
3035
3970
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_expires ON tokens (expires_at)");
@@ -3052,9 +3987,9 @@ class SqliteTokenStore {
3052
3987
  }
3053
3988
  #insert(r) {
3054
3989
  this.#db.query(`INSERT OR REPLACE INTO tokens (hash, kind, grant_id, address_id, client_id,
3055
- client_name, scopes, grant_created_at, issued_at, expires_at, used_at)
3990
+ client_name, scopes, grant_created_at, issued_at, expires_at, used_at, subject)
3056
3991
  VALUES ($hash, $kind, $grant_id, $address_id, $client_id, $client_name,
3057
- $scopes, $grant_created_at, $issued_at, $expires_at, $used_at)`).run({
3992
+ $scopes, $grant_created_at, $issued_at, $expires_at, $used_at, $subject)`).run({
3058
3993
  hash: r.hash,
3059
3994
  kind: r.kind,
3060
3995
  grant_id: r.grantId,
@@ -3065,7 +4000,8 @@ class SqliteTokenStore {
3065
4000
  grant_created_at: r.grantCreatedAt,
3066
4001
  issued_at: r.issuedAt,
3067
4002
  expires_at: r.expiresAt,
3068
- used_at: r.usedAt ?? null
4003
+ used_at: r.usedAt ?? null,
4004
+ subject: r.subject ?? null
3069
4005
  });
3070
4006
  }
3071
4007
  async get(hash) {
@@ -3176,6 +4112,7 @@ function startRelay(options, hooks) {
3176
4112
  const appNames = new Map;
3177
4113
  let claudeWorkarounds = 0;
3178
4114
  let publicUrl = config.publicUrl ?? "";
4115
+ const assertions = config.oidc === undefined ? undefined : new TabAssertionVerifier(config.oidc, hooks?.assertionKeys);
3179
4116
  const corsHeaders = config.cors ? {
3180
4117
  "access-control-allow-origin": "*",
3181
4118
  "access-control-expose-headers": "www-authenticate, mcp-protocol-version"
@@ -3223,6 +4160,105 @@ function startRelay(options, hooks) {
3223
4160
  for (const g of grants)
3224
4161
  grantRemoved(addressId, g.grantId, "revoked");
3225
4162
  },
4163
+ async bindIdentity(session, assertion, round) {
4164
+ const addressId = session.addressId;
4165
+ if (addressId === undefined)
4166
+ return;
4167
+ const echo = round === undefined ? {} : { round };
4168
+ const answer = (message) => {
4169
+ if (!session.send(message)) {
4170
+ logger.warn("identity.answer_dropped", {
4171
+ address: shortAddress(addressId),
4172
+ type: message.type
4173
+ });
4174
+ }
4175
+ };
4176
+ const refuse = (reason, message) => {
4177
+ if (session.identity !== undefined) {
4178
+ session.identity = undefined;
4179
+ failPending(session, "The page's user is no longer signed in.");
4180
+ }
4181
+ logger.info("identity.refused", {
4182
+ address: shortAddress(addressId),
4183
+ reason
4184
+ });
4185
+ answer({ type: "error", code: "identity_refused", message, ...echo });
4186
+ };
4187
+ if (session.site.pairing !== "oidc" || assertions === undefined) {
4188
+ refuse("not_oidc", "this page's site does not use identity binding");
4189
+ return;
4190
+ }
4191
+ let key;
4192
+ try {
4193
+ key = await assertions.verify(assertion, `${publicUrl}/a/${addressId}/mcp`);
4194
+ } catch (error) {
4195
+ refuse(error instanceof JwtError ? error.reason : "error", `the identity assertion was refused: ${error instanceof JwtError ? error.message : "the relay could not check it"}`);
4196
+ return;
4197
+ }
4198
+ if (!session.ready)
4199
+ return;
4200
+ const changed = session.identity !== key;
4201
+ if (changed && session.identity !== undefined) {
4202
+ session.identity = undefined;
4203
+ failPending(session, "The page's user changed.");
4204
+ }
4205
+ let revoked = 0;
4206
+ try {
4207
+ const grants = await store.listGrants(addressId, Date.now());
4208
+ for (const g of grants.filter((g) => g.subject !== key)) {
4209
+ await store.revoke({ grantId: g.grantId }, revokeOptions());
4210
+ grantRemoved(addressId, g.grantId, "revoked");
4211
+ revoked++;
4212
+ }
4213
+ } catch (error) {
4214
+ logger.error("store.error", {
4215
+ op: "identity",
4216
+ message: error instanceof Error ? error.message : "unknown"
4217
+ });
4218
+ refuse("store_unavailable", "the relay cannot check grants right now");
4219
+ return;
4220
+ }
4221
+ if (!session.ready)
4222
+ return;
4223
+ session.identity = key;
4224
+ answer({ type: "identity.bound", ...echo });
4225
+ logger.info("identity.bound", {
4226
+ address: shortAddress(addressId),
4227
+ changed,
4228
+ revoked
4229
+ });
4230
+ host.ready(session);
4231
+ },
4232
+ async signOut(session) {
4233
+ const addressId = session.addressId;
4234
+ if (addressId === undefined)
4235
+ return;
4236
+ if (session.site.pairing !== "oidc") {
4237
+ session.send({
4238
+ type: "error",
4239
+ code: "identity_refused",
4240
+ message: "this page's site does not use identity binding"
4241
+ });
4242
+ return;
4243
+ }
4244
+ session.identity = undefined;
4245
+ failPending(session, "The page's user signed out.");
4246
+ try {
4247
+ const grants = await store.listGrants(addressId, Date.now());
4248
+ await store.revoke({ addressId }, revokeOptions());
4249
+ logger.info("identity.signed_out", {
4250
+ address: shortAddress(addressId),
4251
+ grants: grants.length
4252
+ });
4253
+ for (const g of grants)
4254
+ grantRemoved(addressId, g.grantId, "revoked");
4255
+ } catch (error) {
4256
+ logger.error("store.error", {
4257
+ op: "signOut",
4258
+ message: error instanceof Error ? error.message : "unknown"
4259
+ });
4260
+ }
4261
+ },
3226
4262
  claimAddress(session) {
3227
4263
  const id = session.addressId;
3228
4264
  if (id === undefined)
@@ -3257,12 +4293,20 @@ function startRelay(options, hooks) {
3257
4293
  wake(session);
3258
4294
  }
3259
4295
  };
3260
- function readySession(addressId) {
4296
+ function failPending(session, message) {
4297
+ for (const exchange of [...session.pending.values()]) {
4298
+ exchange.fail(401, RelayErrorCode.signedOut, message, true);
4299
+ }
4300
+ }
4301
+ function readySession(addressId, usable = () => true) {
3261
4302
  const s = sessions.get(addressId);
3262
- return s?.ready === true ? s : undefined;
4303
+ return s?.ready === true && usable(s) ? s : undefined;
3263
4304
  }
3264
- function waitForPage(addressId, signal, waitMs) {
3265
- const now = readySession(addressId);
4305
+ function usableFor(subject) {
4306
+ return (s) => subject === undefined && s.site.pairing === "code" || s.identity !== undefined;
4307
+ }
4308
+ function waitForPage(addressId, signal, waitMs, usable) {
4309
+ const now = readySession(addressId, usable);
3266
4310
  if (now !== undefined)
3267
4311
  return Promise.resolve(now);
3268
4312
  if (waitMs === 0 || stopping !== undefined) {
@@ -3288,7 +4332,10 @@ function startRelay(options, hooks) {
3288
4332
  }
3289
4333
  resolve(result);
3290
4334
  };
3291
- const wake = (session) => done(session);
4335
+ const wake = (session) => {
4336
+ if (session === undefined || usable(session))
4337
+ done(session);
4338
+ };
3292
4339
  const onAbort = () => done("aborted");
3293
4340
  set.add(wake);
3294
4341
  waiters.set(addressId, set);
@@ -3522,18 +4569,26 @@ function startRelay(options, hooks) {
3522
4569
  logDone(499, method, { outcome: "client_closed" });
3523
4570
  return new Response(null, { status: 499 });
3524
4571
  };
3525
- if (readySession(addressId) === undefined && waitMs > 0) {
4572
+ const usable = usableFor(auth.subject);
4573
+ if (readySession(addressId, usable) === undefined && waitMs > 0) {
3526
4574
  server.timeout(req, 0);
3527
4575
  }
3528
4576
  let retried = false;
3529
4577
  for (;; ) {
3530
- const found = await waitForPage(addressId, req.signal, waitMs);
4578
+ const found = await waitForPage(addressId, req.signal, waitMs, usable);
3531
4579
  if (found === "aborted" || req.signal.aborted)
3532
4580
  return closed();
3533
4581
  if (found === undefined) {
3534
4582
  return pageNotOpen(body.value, rpcId, addressId, logDone, method);
3535
4583
  }
3536
4584
  const session = found;
4585
+ if ((auth.subject !== undefined || session.site.pairing === "oidc") && session.identity !== auth.subject) {
4586
+ logDone(401, method, { reason: "identity_mismatch" });
4587
+ return json(401, { error: "invalid_token", error_description: MISMATCH_TEXT }, {
4588
+ ...corsHeaders,
4589
+ "www-authenticate": `${wwwAuthenticate(addressId, true)}, error_description="${MISMATCH_TEXT}"`
4590
+ });
4591
+ }
3537
4592
  if (Buffer.byteLength(body.text) > session.limits.maxMessageBytes) {
3538
4593
  logDone(413, method);
3539
4594
  return rpcError(413, rpcId, -32600, "Request body too large.", corsHeaders);
@@ -3589,7 +4644,9 @@ function startRelay(options, hooks) {
3589
4644
  }
3590
4645
  }
3591
4646
  function pageNotOpen(value, rpcId, addressId, logDone, method) {
3592
- const answer = notOpenAnswer(value, rpcId, appNames.get(addressId), "not_open", RELAY_INFO);
4647
+ const open = sessions.get(addressId);
4648
+ const phase = open?.ready === true && open.identity === undefined && open.site.pairing === "oidc" ? "signed_out" : "not_open";
4649
+ const answer = notOpenAnswer(value, rpcId, open?.appName ?? appNames.get(addressId), phase, RELAY_INFO);
3593
4650
  if (answer === undefined) {
3594
4651
  logDone(503, method, { reason: "page_not_open" });
3595
4652
  return rpcError(503, rpcId, RelayErrorCode.pageNotOpen, "The page is not open.", corsHeaders);
@@ -3658,6 +4715,9 @@ function startRelay(options, hooks) {
3658
4715
  GET: (req, srv) => auth.authorize(req, ipOf(req, srv)),
3659
4716
  POST: (req, srv) => auth.authorizeSubmit(req, ipOf(req, srv))
3660
4717
  },
4718
+ "/authorize/callback": {
4719
+ GET: (req, srv) => auth.authorizeCallback(req, ipOf(req, srv))
4720
+ },
3661
4721
  "/authorize.css": { GET: () => auth.stylesheet() },
3662
4722
  "/token": {
3663
4723
  POST: (req, srv) => auth.token(req, ipOf(req, srv)),
@@ -3808,6 +4868,16 @@ Configuration is read from environment variables:
3808
4868
  go beyond it (7776000 = 90 days)
3809
4869
  TAB_MCP_LOG_LEVEL error | warn | info | debug (info); bodies are never logged
3810
4870
 
4871
+ Identity binding (pairing by the same sign-in instead of a code; docs/configuration.md):
4872
+ TAB_MCP_IDENTITY code | oidc: pairing of sites without their own (code)
4873
+ TAB_MCP_OIDC_ISSUER the OpenID Connect provider's issuer (required for oidc)
4874
+ TAB_MCP_OIDC_CLIENT_ID the relay's client at the provider (required for oidc);
4875
+ register <public URL>/authorize/callback as redirect URI
4876
+ TAB_MCP_OIDC_CLIENT_SECRET its secret (empty = public client, PKCE only)
4877
+ TAB_MCP_OIDC_SUBJECT_CLAIM sub | oid: the ID token claim compared (sub; oid for Entra ID)
4878
+ TAB_MCP_TAB_ASSERTION_ISSUER iss of the web app's tab assertions (required for oidc)
4879
+ TAB_MCP_TAB_ASSERTION_JWKS_URL JWKS of the web app's assertion keys (required for oidc)
4880
+
3811
4881
  Stops on SIGTERM or SIGINT: tabs get "bye" and close code 4004.
3812
4882
  `;
3813
4883
  var args = process.argv.slice(2);
@@ -3846,5 +4916,5 @@ var shutdown = () => {
3846
4916
  process.on("SIGTERM", shutdown);
3847
4917
  process.on("SIGINT", shutdown);
3848
4918
 
3849
- //# debugId=8EA68051C77BF17764756E2164756E21
4919
+ //# debugId=16AE1B87E7D1BB3C64756E2164756E21
3850
4920
  //# sourceMappingURL=main.js.map