@ractive-ch/tab-mcp 0.2.0 → 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/index.js CHANGED
@@ -45,6 +45,7 @@ var DEFAULT_CONFIG = {
45
45
  host: "0.0.0.0",
46
46
  allowedOrigins: [],
47
47
  sites: [],
48
+ identity: "code",
48
49
  clientIdHosts: [],
49
50
  trustProxy: "none",
50
51
  cors: true,
@@ -112,6 +113,7 @@ function resolveConfig(input = {}) {
112
113
  }
113
114
  seen.add(site.origin);
114
115
  }
116
+ validateIdentity(config);
115
117
  config.clientIdHosts = config.clientIdHosts.map((h) => {
116
118
  const host = h.trim().toLowerCase();
117
119
  if (!/^[a-z0-9.-]+(:\d+)?$/.test(host)) {
@@ -194,13 +196,12 @@ function validateSite(input) {
194
196
  out.name = site["name"];
195
197
  }
196
198
  const pairing = site["pairing"];
197
- if (pairing !== undefined && pairing !== "code") {
198
- if (pairing === "oidc") {
199
- throw new ConfigError(`pairing "oidc" (identity binding) is planned but not available yet: ${out.origin}`);
199
+ if (pairing !== undefined) {
200
+ if (pairing !== "code" && pairing !== "oidc") {
201
+ throw new ConfigError(`pairing must be "code" or "oidc": ${out.origin}`);
200
202
  }
201
- throw new ConfigError(`pairing must be "code": ${out.origin}`);
203
+ out.pairing = pairing;
202
204
  }
203
- out.pairing = "code";
204
205
  const limits = site["limits"];
205
206
  if (limits !== undefined) {
206
207
  if (typeof limits !== "object" || limits === null) {
@@ -224,6 +225,57 @@ function validateSite(input) {
224
225
  }
225
226
  return out;
226
227
  }
228
+ function checkUrl(value, name, development) {
229
+ if (typeof value !== "string" || value === "") {
230
+ throw new ConfigError(`${name} is required for identity binding`);
231
+ }
232
+ let url;
233
+ try {
234
+ url = new URL(value);
235
+ } catch {
236
+ throw new ConfigError(`${name} is not a URL: ${value}`);
237
+ }
238
+ if (url.protocol !== "https:" && !(development && url.protocol === "http:")) {
239
+ throw new ConfigError(`${name} must be https: ${value}`);
240
+ }
241
+ if (url.search !== "" || url.hash !== "" || url.username !== "" || url.password !== "") {
242
+ throw new ConfigError(`${name} has no query, fragment or credentials: ${value}`);
243
+ }
244
+ return value;
245
+ }
246
+ function validateIdentity(config) {
247
+ if (config.identity !== "code" && config.identity !== "oidc") {
248
+ throw new ConfigError('identity must be "code" or "oidc"');
249
+ }
250
+ const used = config.sites.some((s) => (s.pairing ?? config.identity) === "oidc") || config.identity === "oidc" && config.allowedOrigins !== "*" && config.allowedOrigins.length > 0;
251
+ if (config.identity === "oidc" && config.allowedOrigins === "*") {
252
+ throw new ConfigError("TAB_MCP_IDENTITY=oidc cannot be used with TAB_MCP_ALLOWED_ORIGINS=* (one provider cannot vouch for any website's users)");
253
+ }
254
+ const oidc = config.oidc;
255
+ if (oidc === undefined) {
256
+ if (used) {
257
+ 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");
258
+ }
259
+ return;
260
+ }
261
+ const dev = config.development;
262
+ checkUrl(oidc.issuer, "TAB_MCP_OIDC_ISSUER", dev);
263
+ checkUrl(oidc.assertionJwksUrl, "TAB_MCP_TAB_ASSERTION_JWKS_URL", dev);
264
+ if (typeof oidc.clientId !== "string" || oidc.clientId === "") {
265
+ throw new ConfigError("TAB_MCP_OIDC_CLIENT_ID is required for identity binding");
266
+ }
267
+ if (oidc.clientSecret !== undefined && typeof oidc.clientSecret !== "string") {
268
+ throw new ConfigError("TAB_MCP_OIDC_CLIENT_SECRET must be a string");
269
+ }
270
+ if (typeof oidc.assertionIssuer !== "string" || oidc.assertionIssuer === "") {
271
+ throw new ConfigError("TAB_MCP_TAB_ASSERTION_ISSUER is required for identity binding");
272
+ }
273
+ const claim = oidc.subjectClaim ?? "sub";
274
+ if (claim !== "sub" && claim !== "oid") {
275
+ throw new ConfigError('TAB_MCP_OIDC_SUBJECT_CLAIM must be "sub" or "oid" (a stable identifier, never email or a user name)');
276
+ }
277
+ config.oidc = { ...oidc, subjectClaim: claim };
278
+ }
227
279
  function defaultLimits(config) {
228
280
  return {
229
281
  maxMessageBytes: config.maxMessageBytes,
@@ -241,7 +293,7 @@ function siteFor(config, origin) {
241
293
  if (configured !== undefined) {
242
294
  const site = {
243
295
  origin: configured.origin,
244
- pairing: configured.pairing ?? "code",
296
+ pairing: configured.pairing ?? config.identity,
245
297
  limits: { ...defaults, ...configured.limits }
246
298
  };
247
299
  if (configured.name !== undefined)
@@ -251,7 +303,7 @@ function siteFor(config, origin) {
251
303
  if (config.allowedOrigins === "*" || origin !== null && config.allowedOrigins.includes(origin)) {
252
304
  return {
253
305
  origin: config.allowedOrigins === "*" ? "*" : origin ?? "",
254
- pairing: "code",
306
+ pairing: config.allowedOrigins === "*" ? "code" : config.identity,
255
307
  limits: defaults
256
308
  };
257
309
  }
@@ -335,6 +387,30 @@ function configFromEnv(env) {
335
387
  if (hosts !== undefined && hosts.trim() !== "") {
336
388
  out.clientIdHosts = hosts.split(",").map((h) => h.trim()).filter((h) => h !== "");
337
389
  }
390
+ set("identity", oneOf(env, "TAB_MCP_IDENTITY", ["code", "oidc"]));
391
+ const oidcVars = {
392
+ issuer: env["TAB_MCP_OIDC_ISSUER"],
393
+ clientId: env["TAB_MCP_OIDC_CLIENT_ID"],
394
+ clientSecret: env["TAB_MCP_OIDC_CLIENT_SECRET"],
395
+ subjectClaim: env["TAB_MCP_OIDC_SUBJECT_CLAIM"],
396
+ assertionIssuer: env["TAB_MCP_TAB_ASSERTION_ISSUER"],
397
+ assertionJwksUrl: env["TAB_MCP_TAB_ASSERTION_JWKS_URL"]
398
+ };
399
+ if (Object.values(oidcVars).some((v) => v !== undefined && v !== "")) {
400
+ const oidc = {
401
+ issuer: oidcVars.issuer ?? "",
402
+ clientId: oidcVars.clientId ?? "",
403
+ assertionIssuer: oidcVars.assertionIssuer ?? "",
404
+ assertionJwksUrl: oidcVars.assertionJwksUrl ?? ""
405
+ };
406
+ if (oidcVars.clientSecret !== undefined && oidcVars.clientSecret !== "") {
407
+ oidc.clientSecret = oidcVars.clientSecret;
408
+ }
409
+ if (oidcVars.subjectClaim !== undefined && oidcVars.subjectClaim !== "") {
410
+ oidc.subjectClaim = oidcVars.subjectClaim;
411
+ }
412
+ out.oidc = oidc;
413
+ }
338
414
  set("trustProxy", oneOf(env, "TAB_MCP_TRUST_PROXY", [
339
415
  "none",
340
416
  "x-real-ip",
@@ -369,6 +445,385 @@ function configFromEnv(env) {
369
445
  ]));
370
446
  return out;
371
447
  }
448
+ // src/jwt.ts
449
+ var JWT_ALGORITHMS = [
450
+ "RS256",
451
+ "RS384",
452
+ "RS512",
453
+ "PS256",
454
+ "PS384",
455
+ "PS512",
456
+ "ES256",
457
+ "ES384",
458
+ "ES512",
459
+ "EdDSA"
460
+ ];
461
+ var MAX_JWT_BYTES = 8192;
462
+ var MAX_JWKS_BYTES = 65536;
463
+ var JWKS_TTL_MS = 10 * 60000;
464
+ var JWKS_REFETCH_MS = 30000;
465
+ var JWKS_GRACE_MS = 60 * 60000;
466
+ var FETCH_TIMEOUT_MS = 5000;
467
+
468
+ class JwtError extends Error {
469
+ name = "JwtError";
470
+ reason;
471
+ constructor(reason, message) {
472
+ super(message);
473
+ this.reason = reason;
474
+ }
475
+ }
476
+ function fromBase64Url(part) {
477
+ if (!/^[A-Za-z0-9_-]*$/.test(part)) {
478
+ throw new JwtError("malformed", "the token is not base64url");
479
+ }
480
+ return Uint8Array.fromBase64(part, { alphabet: "base64url" });
481
+ }
482
+ function jsonPart(part, what) {
483
+ let value;
484
+ try {
485
+ value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(fromBase64Url(part)));
486
+ } catch {
487
+ throw new JwtError("malformed", `the token's ${what} is not JSON`);
488
+ }
489
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
490
+ throw new JwtError("malformed", `the token's ${what} is not a JSON object`);
491
+ }
492
+ return value;
493
+ }
494
+ function decodeJwt(token) {
495
+ if (typeof token !== "string" || token.length > MAX_JWT_BYTES) {
496
+ throw new JwtError("too_large", "the token is too large");
497
+ }
498
+ const parts = token.split(".");
499
+ if (parts.length !== 3) {
500
+ throw new JwtError("malformed", "the token is not a signed JWT");
501
+ }
502
+ const [h = "", p = "", s = ""] = parts;
503
+ return {
504
+ header: jsonPart(h, "header"),
505
+ payload: jsonPart(p, "payload"),
506
+ signingInput: new TextEncoder().encode(`${h}.${p}`),
507
+ signature: fromBase64Url(s)
508
+ };
509
+ }
510
+ function spec(alg) {
511
+ const bits = alg.slice(2);
512
+ const hash = `SHA-${bits}`;
513
+ switch (alg.slice(0, 2)) {
514
+ case "RS":
515
+ return {
516
+ kty: "RSA",
517
+ importParams: { name: "RSASSA-PKCS1-v1_5", hash },
518
+ verifyParams: { name: "RSASSA-PKCS1-v1_5" }
519
+ };
520
+ case "PS":
521
+ return {
522
+ kty: "RSA",
523
+ importParams: { name: "RSA-PSS", hash },
524
+ verifyParams: {
525
+ name: "RSA-PSS",
526
+ saltLength: Number(bits) / 8
527
+ }
528
+ };
529
+ case "ES": {
530
+ const crv = bits === "512" ? "P-521" : `P-${bits}`;
531
+ return {
532
+ kty: "EC",
533
+ crv,
534
+ importParams: { name: "ECDSA", namedCurve: crv },
535
+ verifyParams: { name: "ECDSA", hash }
536
+ };
537
+ }
538
+ default:
539
+ return {
540
+ kty: "OKP",
541
+ crv: "Ed25519",
542
+ importParams: { name: "Ed25519" },
543
+ verifyParams: { name: "Ed25519" }
544
+ };
545
+ }
546
+ }
547
+ function isAlgorithm(v) {
548
+ return JWT_ALGORITHMS.includes(v);
549
+ }
550
+ function keyFits(jwk, alg) {
551
+ const s = spec(alg);
552
+ if (jwk.kty !== s.kty)
553
+ return false;
554
+ if (s.crv !== undefined && jwk.crv !== s.crv)
555
+ return false;
556
+ if (jwk.use !== undefined && jwk.use !== "sig")
557
+ return false;
558
+ if (jwk.alg !== undefined && jwk.alg !== alg)
559
+ return false;
560
+ return true;
561
+ }
562
+ function publicJwk(jwk) {
563
+ const out = { kty: jwk.kty };
564
+ for (const k of ["n", "e", "crv", "x", "y"]) {
565
+ const v = jwk[k];
566
+ if (typeof v === "string")
567
+ out[k] = v;
568
+ }
569
+ return out;
570
+ }
571
+ async function fetchJson(url, init = {}, maxBytes = MAX_JWKS_BYTES) {
572
+ const res = await fetch(url, {
573
+ ...init,
574
+ redirect: "error",
575
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
576
+ headers: { accept: "application/json", ...init.headers ?? {} }
577
+ });
578
+ const length = Number(res.headers.get("content-length") ?? "0");
579
+ if (length > maxBytes)
580
+ throw new Error(`response too large (${url})`);
581
+ const chunks = [];
582
+ let size = 0;
583
+ const reader = res.body?.getReader();
584
+ while (reader !== undefined) {
585
+ const { done, value } = await reader.read();
586
+ if (done)
587
+ break;
588
+ size += value.byteLength;
589
+ if (size > maxBytes) {
590
+ await reader.cancel();
591
+ throw new Error(`response too large (${url})`);
592
+ }
593
+ chunks.push(value);
594
+ }
595
+ const bytes = new Uint8Array(size);
596
+ let offset = 0;
597
+ for (const chunk of chunks) {
598
+ bytes.set(chunk, offset);
599
+ offset += chunk.byteLength;
600
+ }
601
+ const text = new TextDecoder().decode(bytes);
602
+ let value;
603
+ try {
604
+ value = JSON.parse(text);
605
+ } catch {
606
+ throw new Error(`not JSON (${url}, HTTP ${res.status})`);
607
+ }
608
+ if (!res.ok) {
609
+ const error = value?.["error"];
610
+ throw new HttpJsonError(res.status, typeof error === "string" ? error : "", url);
611
+ }
612
+ return value;
613
+ }
614
+
615
+ class HttpJsonError extends Error {
616
+ name = "HttpJsonError";
617
+ status;
618
+ error;
619
+ constructor(status, error, url) {
620
+ super(`HTTP ${status}${error === "" ? "" : ` ${error}`} (${url})`);
621
+ this.status = status;
622
+ this.error = error;
623
+ }
624
+ }
625
+
626
+ class JwksCache {
627
+ #url;
628
+ #now;
629
+ #keys = [];
630
+ #fetchedAt = 0;
631
+ #attemptedAt = Number.NEGATIVE_INFINITY;
632
+ #lastError;
633
+ #inflight;
634
+ constructor(url, options = {}) {
635
+ this.#url = typeof url === "string" ? () => Promise.resolve(url) : url;
636
+ this.#now = options.now ?? Date.now;
637
+ }
638
+ async keys(kid) {
639
+ if (this.#inflight !== undefined)
640
+ await this.#inflight.catch(() => {});
641
+ const now = this.#now();
642
+ const stale = now - this.#fetchedAt >= JWKS_TTL_MS;
643
+ const unknown = kid !== undefined && !this.#keys.some((k) => k.kid === kid);
644
+ if ((stale || unknown) && now - this.#attemptedAt >= JWKS_REFETCH_MS) {
645
+ try {
646
+ await this.#refresh();
647
+ } catch (error) {
648
+ this.#lastError = error instanceof Error ? error : new Error("JWKS");
649
+ }
650
+ }
651
+ if (this.#now() - this.#fetchedAt >= JWKS_TTL_MS + JWKS_GRACE_MS) {
652
+ throw this.#lastError ?? new Error("the JWKS could not be loaded");
653
+ }
654
+ return kid === undefined ? this.#keys : this.#keys.filter((k) => k.kid === kid);
655
+ }
656
+ #refresh() {
657
+ this.#inflight ??= (async () => {
658
+ this.#attemptedAt = this.#now();
659
+ try {
660
+ const doc = await fetchJson(await this.#url());
661
+ const keys = doc?.keys;
662
+ if (!Array.isArray(keys))
663
+ throw new Error("a JWKS needs keys");
664
+ this.#keys = keys.filter((k) => typeof k === "object" && k !== null);
665
+ this.#fetchedAt = this.#now();
666
+ this.#lastError = undefined;
667
+ } finally {
668
+ this.#inflight = undefined;
669
+ }
670
+ })();
671
+ return this.#inflight;
672
+ }
673
+ }
674
+ async function verifyJwt(token, source, options = {}) {
675
+ const jwt = decodeJwt(token);
676
+ const { header } = jwt;
677
+ const alg = header["alg"];
678
+ const allowed = options.algorithms ?? JWT_ALGORITHMS;
679
+ if (!isAlgorithm(alg) || !allowed.includes(alg)) {
680
+ throw new JwtError("alg", `the token's algorithm is not accepted (${String(alg).slice(0, 20)})`);
681
+ }
682
+ if (header["crit"] !== undefined) {
683
+ throw new JwtError("crit", "the token has critical header parameters");
684
+ }
685
+ if (options.typ !== undefined && header["typ"] !== options.typ) {
686
+ throw new JwtError("typ", `the token's type must be ${options.typ}`);
687
+ }
688
+ const kid = header["kid"];
689
+ if (kid !== undefined && typeof kid !== "string") {
690
+ throw new JwtError("malformed", "the token's kid is not a string");
691
+ }
692
+ let candidates;
693
+ try {
694
+ candidates = (await source.keys(kid)).filter((k) => keyFits(k, alg));
695
+ } catch (error) {
696
+ throw new JwtError("jwks_unavailable", `the signing keys could not be loaded: ${error instanceof Error ? error.message : "unknown"}`);
697
+ }
698
+ if (candidates.length === 0 || kid === undefined && candidates.length > 1) {
699
+ throw new JwtError("key", "no matching signing key");
700
+ }
701
+ const s = spec(alg);
702
+ for (const jwk of candidates) {
703
+ let key;
704
+ try {
705
+ key = await crypto.subtle.importKey("jwk", publicJwk(jwk), s.importParams, false, ["verify"]);
706
+ } catch {
707
+ continue;
708
+ }
709
+ const ok = await crypto.subtle.verify(s.verifyParams, key, jwt.signature, jwt.signingInput).catch(() => false);
710
+ if (ok)
711
+ return jwt;
712
+ }
713
+ throw new JwtError("signature", "the token's signature is not valid");
714
+ }
715
+ function numericDate(payload, name) {
716
+ const v = payload[name];
717
+ if (v === undefined)
718
+ return;
719
+ if (typeof v !== "number" || !Number.isFinite(v)) {
720
+ throw new JwtError("claims", `${name} must be a number`);
721
+ }
722
+ return v * 1000;
723
+ }
724
+ function audiences(payload) {
725
+ const aud = payload["aud"];
726
+ if (typeof aud === "string")
727
+ return [aud];
728
+ if (Array.isArray(aud) && aud.every((a) => typeof a === "string")) {
729
+ return aud;
730
+ }
731
+ throw new JwtError("claims", "aud is missing");
732
+ }
733
+
734
+ // src/identity.ts
735
+ var TAB_ASSERTION_TYP = "tab-mcp-identity+jwt";
736
+ var TAB_ASSERTION_MAX_LIFETIME_MS = 60000;
737
+ var CLOCK_LEEWAY_MS = 5000;
738
+ var MAX_SUBJECT = 255;
739
+ var MAX_JTI = 128;
740
+ var MAX_SEEN_JTI = 1e5;
741
+ var MAX_JTI_PER_PERSON = 120;
742
+ var MISMATCH_TEXT = "This AI tool is logged in as someone other than the page.";
743
+ function identityKey(issuer, subject) {
744
+ return new Bun.CryptoHasher("sha256").update(`${issuer}
745
+ ${subject}`).digest().toBase64({ alphabet: "base64url", omitPadding: true });
746
+ }
747
+
748
+ class TabAssertionVerifier {
749
+ #oidc;
750
+ #keys;
751
+ #seen = new Map;
752
+ #perPerson = new Map;
753
+ #sweptAt = Number.NEGATIVE_INFINITY;
754
+ constructor(oidc, keys) {
755
+ this.#oidc = oidc;
756
+ this.#keys = keys ?? new JwksCache(oidc.assertionJwksUrl);
757
+ }
758
+ async verify(token, address, now = Date.now()) {
759
+ const { payload } = await verifyJwt(token, this.#keys, {
760
+ typ: TAB_ASSERTION_TYP
761
+ });
762
+ if (payload["iss"] !== this.#oidc.assertionIssuer) {
763
+ throw new JwtError("iss", "the assertion's issuer is not the configured one");
764
+ }
765
+ const aud = audiences(payload);
766
+ if (aud.length !== 1 || aud[0] !== address) {
767
+ throw new JwtError("aud", "the assertion is not for this address");
768
+ }
769
+ const exp = numericDate(payload, "exp");
770
+ if (exp === undefined)
771
+ throw new JwtError("exp", "the assertion has no exp");
772
+ if (exp <= now - CLOCK_LEEWAY_MS) {
773
+ throw new JwtError("expired", "the assertion has expired");
774
+ }
775
+ if (exp > now + TAB_ASSERTION_MAX_LIFETIME_MS + CLOCK_LEEWAY_MS) {
776
+ throw new JwtError("lifetime", "the assertion lives longer than 60 seconds");
777
+ }
778
+ const iat = numericDate(payload, "iat");
779
+ if (iat !== undefined && iat > now + CLOCK_LEEWAY_MS) {
780
+ throw new JwtError("iat", "the assertion was issued in the future");
781
+ }
782
+ const nbf = numericDate(payload, "nbf");
783
+ if (nbf !== undefined && nbf > now + CLOCK_LEEWAY_MS) {
784
+ throw new JwtError("nbf", "the assertion is not valid yet");
785
+ }
786
+ const sub = payload["sub"];
787
+ if (typeof sub !== "string" || sub === "" || sub.length > MAX_SUBJECT) {
788
+ throw new JwtError("sub", "the assertion needs a subject");
789
+ }
790
+ const jti = payload["jti"];
791
+ if (typeof jti !== "string" || jti === "" || jti.length > MAX_JTI) {
792
+ throw new JwtError("jti", "the assertion needs a jti");
793
+ }
794
+ this.#sweep(now);
795
+ const key = identityKey(this.#oidc.issuer, sub);
796
+ const seenKey = `${key}
797
+ ${jti}`;
798
+ if (this.#seen.has(seenKey)) {
799
+ throw new JwtError("replayed", "the assertion was already used");
800
+ }
801
+ const mine = this.#perPerson.get(key) ?? 0;
802
+ if (mine >= MAX_JTI_PER_PERSON || this.#seen.size >= MAX_SEEN_JTI) {
803
+ throw new JwtError("busy", "too many assertions at once");
804
+ }
805
+ this.#seen.set(seenKey, exp + CLOCK_LEEWAY_MS);
806
+ this.#perPerson.set(key, mine + 1);
807
+ return key;
808
+ }
809
+ #sweep(now) {
810
+ if (now - this.#sweptAt < 1000 && this.#seen.size < MAX_SEEN_JTI)
811
+ return;
812
+ this.#sweptAt = now;
813
+ for (const [seenKey, until] of this.#seen) {
814
+ if (until > now)
815
+ continue;
816
+ this.#seen.delete(seenKey);
817
+ const person = seenKey.slice(0, seenKey.indexOf(`
818
+ `));
819
+ const left = (this.#perPerson.get(person) ?? 1) - 1;
820
+ if (left > 0)
821
+ this.#perPerson.set(person, left);
822
+ else
823
+ this.#perPerson.delete(person);
824
+ }
825
+ }
826
+ }
372
827
  // src/ip.ts
373
828
  function parseIpv4(ip) {
374
829
  const parts = ip.split(".");
@@ -698,7 +1153,19 @@ function parseTabMessage(text) {
698
1153
  case "pairing.start":
699
1154
  case "pairing.stop":
700
1155
  case "grants.revokeAll":
1156
+ case "identity.logout":
701
1157
  return { ok: true, message: { type } };
1158
+ case "identity": {
1159
+ const assertion = v["assertion"];
1160
+ if (typeof assertion !== "string" || assertion === "" || assertion.length > MAX_JWT_BYTES) {
1161
+ return bad(`identity.assertion must be a JWT of at most ${MAX_JWT_BYTES} bytes`);
1162
+ }
1163
+ const round = v["round"];
1164
+ if (round !== undefined && !(Number.isSafeInteger(round) && round >= 0)) {
1165
+ return bad("identity.round must be a non-negative integer");
1166
+ }
1167
+ return round === undefined ? { ok: true, message: { type, assertion } } : { ok: true, message: { type, assertion, round } };
1168
+ }
702
1169
  case "grant.revoke": {
703
1170
  const grantId = v["grantId"];
704
1171
  if (!isId(grantId))
@@ -724,7 +1191,7 @@ function parseTabMessage(text) {
724
1191
  }
725
1192
  }
726
1193
  // package.json
727
- var version = "0.2.0";
1194
+ var version = "0.3.0";
728
1195
 
729
1196
  // src/exchange.ts
730
1197
  var RelayErrorCode = {
@@ -735,7 +1202,8 @@ var RelayErrorCode = {
735
1202
  sendFailed: -31005,
736
1203
  tooLarge: -31006,
737
1204
  badResponse: -31007,
738
- storeUnavailable: -31008
1205
+ storeUnavailable: -31008,
1206
+ signedOut: -31009
739
1207
  };
740
1208
  var encoder = new TextEncoder;
741
1209
  var NULL_BODY_STATUSES = new Set([204, 205, 304]);
@@ -1056,6 +1524,12 @@ function pageName(appName) {
1056
1524
  function notOpenText(appName) {
1057
1525
  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.`;
1058
1526
  }
1527
+ function signedOutText(appName) {
1528
+ return `The page is open, but nobody is signed in to it. Ask the user to sign in to ${pageName(appName)}, then try again.`;
1529
+ }
1530
+ function phaseText(phase, appName) {
1531
+ return phase === "signed_out" ? signedOutText(appName) : notOpenText(appName);
1532
+ }
1059
1533
  function goneDuringCallText(appName) {
1060
1534
  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.`;
1061
1535
  }
@@ -1065,7 +1539,7 @@ function notOpenAnswer(body, rpcId, appName, phase, relay) {
1065
1539
  const id = rpcId;
1066
1540
  const method = body["method"];
1067
1541
  if (method === "tools/call") {
1068
- const text = phase === "gone" ? goneDuringCallText(appName) : notOpenText(appName);
1542
+ const text = phase === "gone" ? goneDuringCallText(appName) : phaseText(phase, appName);
1069
1543
  return JSON.stringify({
1070
1544
  jsonrpc: "2.0",
1071
1545
  id,
@@ -1084,7 +1558,7 @@ function notOpenAnswer(body, rpcId, appName, phase, relay) {
1084
1558
  resultType: "complete",
1085
1559
  supportedVersions: [MCP_PROTOCOL_VERSION],
1086
1560
  capabilities: { tools: { listChanged: true } },
1087
- instructions: notOpenText(appName),
1561
+ instructions: phaseText(phase, appName),
1088
1562
  ttlMs: 0,
1089
1563
  cacheScope: "private",
1090
1564
  _meta: {
@@ -1101,7 +1575,7 @@ function notOpenAnswer(body, rpcId, appName, phase, relay) {
1101
1575
  id,
1102
1576
  error: {
1103
1577
  code: phase === "gone" ? RelayErrorCode.tabGone : RelayErrorCode.pageNotOpen,
1104
- message: notOpenText(appName)
1578
+ message: phaseText(phase, appName)
1105
1579
  }
1106
1580
  });
1107
1581
  }
@@ -1178,7 +1652,8 @@ async function issueTokenPair(store, grant, options) {
1178
1652
  clientName: grant.clientName,
1179
1653
  scopes: grant.scopes,
1180
1654
  grantCreatedAt: grant.grantCreatedAt,
1181
- issuedAt: now
1655
+ issuedAt: now,
1656
+ ...grant.subject === undefined ? {} : { subject: grant.subject }
1182
1657
  };
1183
1658
  const accessExpires = Math.min(now + options.accessTtlMs, options.grantEndsAt);
1184
1659
  const refreshStored = await store.put({
@@ -1569,6 +2044,348 @@ ${body}
1569
2044
  </html>`;
1570
2045
  }
1571
2046
 
2047
+ // src/oauth/oidc.ts
2048
+ var FLOW_TTL_MS = 10 * 60000;
2049
+ var MAX_FLOWS = 1e4;
2050
+ var METADATA_TTL_MS = 60 * 60000;
2051
+ var MAX_TOKEN_RESPONSE_BYTES = 65536;
2052
+
2053
+ class OidcError extends Error {
2054
+ name = "OidcError";
2055
+ reason;
2056
+ constructor(reason, message) {
2057
+ super(message);
2058
+ this.reason = reason;
2059
+ }
2060
+ }
2061
+ function random2(bytes) {
2062
+ return crypto.getRandomValues(new Uint8Array(bytes)).toBase64({ alphabet: "base64url", omitPadding: true });
2063
+ }
2064
+ function s256(verifier) {
2065
+ return new Bun.CryptoHasher("sha256").update(verifier).digest().toBase64({ alphabet: "base64url", omitPadding: true });
2066
+ }
2067
+ function str(v) {
2068
+ return typeof v === "string" && v !== "" ? v : undefined;
2069
+ }
2070
+ function originOf(url) {
2071
+ try {
2072
+ return new URL(url).origin;
2073
+ } catch {
2074
+ return;
2075
+ }
2076
+ }
2077
+
2078
+ class ProviderDiscovery {
2079
+ #issuer;
2080
+ #development;
2081
+ #metadata;
2082
+ #fetchedAt = 0;
2083
+ #inflight;
2084
+ constructor(issuer, development) {
2085
+ this.#issuer = issuer;
2086
+ this.#development = development;
2087
+ }
2088
+ get cached() {
2089
+ return this.#metadata;
2090
+ }
2091
+ async get() {
2092
+ if (this.#metadata !== undefined && Date.now() - this.#fetchedAt < METADATA_TTL_MS) {
2093
+ return this.#metadata;
2094
+ }
2095
+ this.#inflight ??= this.#load().finally(() => {
2096
+ this.#inflight = undefined;
2097
+ });
2098
+ return this.#inflight;
2099
+ }
2100
+ async#load() {
2101
+ const url = `${this.#issuer.replace(/\/+$/, "")}/.well-known/openid-configuration`;
2102
+ const doc = await fetchJson(url);
2103
+ if (typeof doc !== "object" || doc === null) {
2104
+ throw new OidcError("discovery", "the provider's metadata is not an object");
2105
+ }
2106
+ if (doc["issuer"] !== this.#issuer) {
2107
+ throw new OidcError("discovery_issuer", "the provider's metadata names another issuer");
2108
+ }
2109
+ const endpoint = (name) => {
2110
+ const v = str(doc[name]);
2111
+ const ok = v !== undefined && (v.startsWith("https://") || this.#development && v.startsWith("http://"));
2112
+ if (!ok) {
2113
+ throw new OidcError("discovery", `the provider's metadata lacks ${name}`);
2114
+ }
2115
+ return v;
2116
+ };
2117
+ const methods = doc["token_endpoint_auth_methods_supported"];
2118
+ const tokenAuth = Array.isArray(methods) && !methods.includes("client_secret_basic") && methods.includes("client_secret_post") ? "client_secret_post" : "client_secret_basic";
2119
+ const metadata = {
2120
+ issuer: this.#issuer,
2121
+ authorizationEndpoint: endpoint("authorization_endpoint"),
2122
+ tokenEndpoint: endpoint("token_endpoint"),
2123
+ jwksUri: endpoint("jwks_uri"),
2124
+ tokenAuth
2125
+ };
2126
+ this.#metadata = metadata;
2127
+ this.#fetchedAt = Date.now();
2128
+ return metadata;
2129
+ }
2130
+ }
2131
+
2132
+ class OidcPairing {
2133
+ kind = "oidc";
2134
+ #config;
2135
+ #publicUrl;
2136
+ #logger;
2137
+ #discovery;
2138
+ #jwks;
2139
+ #flows = new Map;
2140
+ #mismatched = new Set;
2141
+ constructor(options) {
2142
+ this.#config = options.config;
2143
+ this.#publicUrl = options.publicUrl;
2144
+ this.#logger = options.logger;
2145
+ this.#discovery = new ProviderDiscovery(options.config.issuer, options.development);
2146
+ this.#jwks = new JwksCache(async () => (await this.#discovery.get()).jwksUri);
2147
+ }
2148
+ get #providerHost() {
2149
+ try {
2150
+ return new URL(this.#config.issuer).host;
2151
+ } catch {
2152
+ return this.#config.issuer;
2153
+ }
2154
+ }
2155
+ get callbackUrl() {
2156
+ return `${this.#publicUrl()}/authorize/callback`;
2157
+ }
2158
+ async prepare() {
2159
+ await this.#discovery.get();
2160
+ }
2161
+ formTargets() {
2162
+ const out = new Set;
2163
+ const issuer = originOf(this.#config.issuer);
2164
+ if (issuer !== undefined)
2165
+ out.add(issuer);
2166
+ const auth = this.#discovery.cached?.authorizationEndpoint;
2167
+ const origin = auth === undefined ? undefined : originOf(auth);
2168
+ if (origin !== undefined)
2169
+ out.add(origin);
2170
+ return [...out];
2171
+ }
2172
+ render(target) {
2173
+ const host = escapeHtml(this.#providerHost);
2174
+ if (target.identity === undefined) {
2175
+ return `<p class="help">Nobody is signed in to the page. Sign in there first, then click Connect.</p>`;
2176
+ }
2177
+ 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>`;
2178
+ }
2179
+ async check(target, _form, _admit, context) {
2180
+ if (!target.pairingOpen) {
2181
+ return {
2182
+ ok: false,
2183
+ message: "The page is not connecting right now. In the page, open \u201CConnect your AI app\u201D, then click Connect here again.",
2184
+ reason: "inactive"
2185
+ };
2186
+ }
2187
+ if (target.identity === undefined) {
2188
+ return {
2189
+ ok: false,
2190
+ message: "Nobody is signed in to the page. Sign in there first, then click Connect here again.",
2191
+ reason: "signed_out"
2192
+ };
2193
+ }
2194
+ if (context === undefined) {
2195
+ return { ok: false, message: "Something went wrong.", reason: "context" };
2196
+ }
2197
+ let metadata;
2198
+ try {
2199
+ metadata = await this.#discovery.get();
2200
+ } catch (error) {
2201
+ return this.#unavailable(error);
2202
+ }
2203
+ this.#sweep();
2204
+ if (this.#flows.size >= MAX_FLOWS) {
2205
+ return {
2206
+ ok: false,
2207
+ message: "Too many sign-ins are open right now. Wait a few minutes and try again.",
2208
+ reason: "busy"
2209
+ };
2210
+ }
2211
+ const state = random2(24);
2212
+ const nonce = random2(24);
2213
+ const verifier = random2(32);
2214
+ this.#flows.set(state, {
2215
+ requestId: context.requestId,
2216
+ browserId: context.browserId,
2217
+ nonce,
2218
+ verifier,
2219
+ expiresAt: Date.now() + FLOW_TTL_MS
2220
+ });
2221
+ const url = new URL(metadata.authorizationEndpoint);
2222
+ const params = {
2223
+ response_type: "code",
2224
+ client_id: this.#config.clientId,
2225
+ redirect_uri: this.callbackUrl,
2226
+ scope: "openid",
2227
+ state,
2228
+ nonce,
2229
+ code_challenge: s256(verifier),
2230
+ code_challenge_method: "S256"
2231
+ };
2232
+ if (this.#mismatched.has(context.requestId))
2233
+ params["prompt"] = "login";
2234
+ for (const [k, v] of Object.entries(params))
2235
+ url.searchParams.set(k, v);
2236
+ return { ok: "redirect", location: url.href };
2237
+ }
2238
+ async callback(params, browserId, targetFor) {
2239
+ this.#sweep();
2240
+ const state = params.get("state") ?? "";
2241
+ const flow = this.#flows.get(state);
2242
+ if (flow === undefined || flow.expiresAt <= Date.now()) {
2243
+ return {
2244
+ result: {
2245
+ ok: false,
2246
+ message: "This sign-in has expired or was already used. Start again from your AI app.",
2247
+ reason: "unknown_state"
2248
+ }
2249
+ };
2250
+ }
2251
+ this.#flows.delete(state);
2252
+ const requestId = flow.requestId;
2253
+ const fail = (reason, message) => ({
2254
+ requestId,
2255
+ result: { ok: false, message, reason }
2256
+ });
2257
+ if (flow.browserId !== browserId) {
2258
+ return fail("browser", "This sign-in was started in another browser. Start again from your AI app.");
2259
+ }
2260
+ const iss = params.get("iss");
2261
+ if (iss !== null && iss !== this.#config.issuer) {
2262
+ return fail("iss", "The sign-in came back from an unexpected provider.");
2263
+ }
2264
+ const error = params.get("error");
2265
+ if (error !== null) {
2266
+ this.#logger.info("oidc.provider_error", { error: error.slice(0, 64) });
2267
+ return fail(`provider_${error.slice(0, 32)}`, `The sign-in at ${this.#providerHost} did not finish. Click Connect to try again.`);
2268
+ }
2269
+ const code = params.get("code");
2270
+ if (code === null || code === "") {
2271
+ return fail("no_code", "The provider sent no sign-in result. Click Connect to try again.");
2272
+ }
2273
+ let subject;
2274
+ try {
2275
+ subject = await this.#signIn(code, flow);
2276
+ } catch (e) {
2277
+ const reason = e instanceof OidcError || e instanceof JwtError ? e.reason : "exchange";
2278
+ this.#logger.warn("oidc.sign_in_failed", {
2279
+ reason,
2280
+ message: e instanceof Error ? e.message.slice(0, 200) : "unknown"
2281
+ });
2282
+ return fail(reason, `The sign-in at ${this.#providerHost} could not be checked. Click Connect to try again.`);
2283
+ }
2284
+ const target = targetFor(requestId);
2285
+ if (target === undefined) {
2286
+ return fail("page_closed", "The page is not open any more. Open it and try again.");
2287
+ }
2288
+ if (target.identity === undefined) {
2289
+ return fail("signed_out", "Nobody is signed in to the page any more. Sign in there first, then click Connect here again.");
2290
+ }
2291
+ const key = identityKey(this.#config.issuer, subject);
2292
+ if (key !== target.identity) {
2293
+ this.#mismatched.add(requestId);
2294
+ if (this.#mismatched.size > MAX_FLOWS) {
2295
+ const oldest = this.#mismatched.values().next().value;
2296
+ if (oldest !== undefined)
2297
+ this.#mismatched.delete(oldest);
2298
+ }
2299
+ return fail("mismatch", `${MISMATCH_TEXT} Click Connect to sign in with the page's account, or Cancel.`);
2300
+ }
2301
+ this.#mismatched.delete(requestId);
2302
+ return { requestId, result: { ok: true, subject: key } };
2303
+ }
2304
+ async#signIn(code, flow) {
2305
+ const metadata = await this.#discovery.get();
2306
+ const body = new URLSearchParams({
2307
+ grant_type: "authorization_code",
2308
+ code,
2309
+ redirect_uri: this.callbackUrl,
2310
+ code_verifier: flow.verifier
2311
+ });
2312
+ const headers = {
2313
+ "content-type": "application/x-www-form-urlencoded"
2314
+ };
2315
+ const secret = this.#config.clientSecret;
2316
+ if (secret !== undefined && metadata.tokenAuth === "client_secret_basic") {
2317
+ const enc = (v) => encodeURIComponent(v).replace(/%20/g, "+");
2318
+ headers["authorization"] = `Basic ${btoa(`${enc(this.#config.clientId)}:${enc(secret)}`)}`;
2319
+ } else {
2320
+ body.set("client_id", this.#config.clientId);
2321
+ if (secret !== undefined)
2322
+ body.set("client_secret", secret);
2323
+ }
2324
+ let response;
2325
+ try {
2326
+ response = await fetchJson(metadata.tokenEndpoint, { method: "POST", headers, body }, MAX_TOKEN_RESPONSE_BYTES);
2327
+ } catch (e) {
2328
+ if (e instanceof HttpJsonError) {
2329
+ throw new OidcError(`token_${e.error || e.status}`, e.message);
2330
+ }
2331
+ throw e;
2332
+ }
2333
+ const idToken = str(response?.["id_token"]);
2334
+ if (idToken === undefined) {
2335
+ throw new OidcError("no_id_token", "the token response has no id_token");
2336
+ }
2337
+ const { payload } = await verifyJwt(idToken, this.#jwks);
2338
+ const now = Date.now();
2339
+ if (payload["iss"] !== metadata.issuer) {
2340
+ throw new OidcError("id_token_iss", "the ID token's issuer is wrong");
2341
+ }
2342
+ const aud = audiences(payload);
2343
+ if (!aud.includes(this.#config.clientId)) {
2344
+ throw new OidcError("id_token_aud", "the ID token is not for this relay");
2345
+ }
2346
+ const azp = payload["azp"];
2347
+ if ((aud.length > 1 || azp !== undefined) && azp !== this.#config.clientId) {
2348
+ throw new OidcError("id_token_azp", "the ID token's azp is not this relay");
2349
+ }
2350
+ const exp = numericDate(payload, "exp");
2351
+ if (exp === undefined || exp <= now - CLOCK_LEEWAY_MS) {
2352
+ throw new OidcError("id_token_exp", "the ID token has expired");
2353
+ }
2354
+ const iat = numericDate(payload, "iat");
2355
+ if (iat === undefined || iat > now + CLOCK_LEEWAY_MS) {
2356
+ throw new OidcError("id_token_iat", "the ID token's iat is missing or in the future");
2357
+ }
2358
+ if (payload["nonce"] !== flow.nonce) {
2359
+ throw new OidcError("id_token_nonce", "the ID token's nonce does not match");
2360
+ }
2361
+ const claim = this.#config.subjectClaim ?? "sub";
2362
+ const subject = payload[claim];
2363
+ if (typeof subject !== "string" || subject === "" || subject.length > 255) {
2364
+ throw new OidcError("id_token_subject", `the ID token has no ${claim}`);
2365
+ }
2366
+ return subject;
2367
+ }
2368
+ #unavailable(error) {
2369
+ this.#logger.warn("oidc.discovery_failed", {
2370
+ reason: error instanceof OidcError ? error.reason : "fetch",
2371
+ message: error instanceof Error ? error.message.slice(0, 200) : "unknown"
2372
+ });
2373
+ return {
2374
+ ok: false,
2375
+ message: `The sign-in provider ${this.#providerHost} cannot be reached right now. Try again in a minute.`,
2376
+ reason: "provider_unavailable"
2377
+ };
2378
+ }
2379
+ #sweep() {
2380
+ const now = Date.now();
2381
+ for (const [state, flow] of this.#flows) {
2382
+ if (flow.expiresAt > now)
2383
+ break;
2384
+ this.#flows.delete(state);
2385
+ }
2386
+ }
2387
+ }
2388
+
1572
2389
  // src/oauth/pairing.ts
1573
2390
  import { timingSafeEqual } from "crypto";
1574
2391
  var PAIRING_CODE_TTL_MS = 120000;
@@ -1720,7 +2537,7 @@ function constantTimeEqual(a, b) {
1720
2537
  const y = Buffer.from(b);
1721
2538
  return x.length === y.length && timingSafeEqual2(x, y);
1722
2539
  }
1723
- function s256(verifier) {
2540
+ function s2562(verifier) {
1724
2541
  return new Bun.CryptoHasher("sha256").update(verifier).digest().toBase64({ alphabet: "base64url", omitPadding: true });
1725
2542
  }
1726
2543
  var VERIFIER = /^[A-Za-z0-9\-._~]{43,128}$/;
@@ -1735,7 +2552,7 @@ function single(params, names) {
1735
2552
  }
1736
2553
  return out;
1737
2554
  }
1738
- function random2(bytes) {
2555
+ function random3(bytes) {
1739
2556
  return crypto.getRandomValues(new Uint8Array(bytes)).toBase64({ alphabet: "base64url", omitPadding: true });
1740
2557
  }
1741
2558
  function formTarget(redirectUri) {
@@ -1764,7 +2581,7 @@ class AuthorizationServer {
1764
2581
  #codes = new Map;
1765
2582
  #authCodeTtlMs;
1766
2583
  #methods;
1767
- #csrfSecret = random2(32);
2584
+ #csrfSecret = random3(32);
1768
2585
  #viewsPerIp = new RateLimiter({
1769
2586
  capacity: 30,
1770
2587
  refillPerSecond: 0.5
@@ -1785,7 +2602,19 @@ class AuthorizationServer {
1785
2602
  constructor(host, testMode) {
1786
2603
  this.#host = host;
1787
2604
  this.#authCodeTtlMs = testMode?.authCodeTtlMs ?? AUTH_CODE_TTL_MS;
1788
- this.#methods = testMode?.pairingMethods ?? PAIRING_METHODS;
2605
+ const oidc = host.config.oidc;
2606
+ this.#methods = {
2607
+ ...PAIRING_METHODS,
2608
+ ...oidc === undefined ? {} : {
2609
+ oidc: new OidcPairing({
2610
+ config: oidc,
2611
+ publicUrl: () => host.publicUrl(),
2612
+ logger: host.logger,
2613
+ development: host.config.development
2614
+ })
2615
+ },
2616
+ ...testMode?.pairingMethods
2617
+ };
1789
2618
  this.#cimd = new ClientMetadataFetcher({
1790
2619
  allowedHosts: host.config.clientIdHosts,
1791
2620
  logger: host.logger,
@@ -1996,12 +2825,23 @@ class AuthorizationServer {
1996
2825
  if (this.#host.session(addressId) === undefined) {
1997
2826
  return this.#offlinePage(client, `/authorize${new URL(req.url).search}`, this.#errorUrl(redirectUri, state, "access_denied", "the user cancelled the connection"));
1998
2827
  }
2828
+ const method = this.#methods[this.#host.session(addressId)?.site.pairing ?? "code"];
2829
+ if (method?.prepare !== undefined) {
2830
+ try {
2831
+ await method.prepare();
2832
+ } catch (error) {
2833
+ this.#host.logger.warn("oidc.discovery_failed", {
2834
+ message: error instanceof Error ? error.message.slice(0, 200) : "unknown"
2835
+ });
2836
+ return this.#errorPage(503, "The sign-in provider of this page cannot be reached right now. Wait a minute and try again.");
2837
+ }
2838
+ }
1999
2839
  this.#sweepPending();
2000
2840
  if (this.#pending.size >= MAX_PENDING || (this.#pendingPerIp.get(ip) ?? 0) >= MAX_PENDING_PER_IP || (this.#pendingPerAddress.get(addressId) ?? 0) >= MAX_PENDING_PER_ADDRESS) {
2001
2841
  return this.#errorPage(429, "Too many connection requests are open right now. Wait a few minutes and try again.");
2002
2842
  }
2003
2843
  const pending = {
2004
- id: random2(24),
2844
+ id: random3(24),
2005
2845
  expiresAt: Date.now() + PENDING_TTL_MS,
2006
2846
  ip,
2007
2847
  client,
@@ -2098,7 +2938,25 @@ ${errorHtml}
2098
2938
  return this.#errorPage(500, "This page's pairing method is not available.");
2099
2939
  }
2100
2940
  const admit = () => this.#guessesPerAddressIp.take(`${pending.addressId}|${ip}`) && this.#guessesPerAddress.take(pending.addressId);
2101
- const result = await method.check(session, form, admit);
2941
+ const result = await method.check(session, form, admit, {
2942
+ requestId: pending.id,
2943
+ browserId: browser.id
2944
+ });
2945
+ if (result.ok === "redirect") {
2946
+ this.#host.logger.info("oauth.pairing_redirect", {
2947
+ address: shortAddress(pending.addressId),
2948
+ client: pending.client.host,
2949
+ method: method.kind
2950
+ });
2951
+ return new Response(null, {
2952
+ status: 303,
2953
+ headers: {
2954
+ location: result.location,
2955
+ "cache-control": "no-store",
2956
+ "referrer-policy": "no-referrer"
2957
+ }
2958
+ });
2959
+ }
2102
2960
  if (!result.ok) {
2103
2961
  this.#host.logger.info("oauth.pairing_failed", {
2104
2962
  address: shortAddress(pending.addressId),
@@ -2107,20 +2965,24 @@ ${errorHtml}
2107
2965
  });
2108
2966
  return this.#pairingPage(pending, browser.id, result.message, result.reason === "rate_limited" ? 429 : 400);
2109
2967
  }
2968
+ return this.#paired(pending, method.kind, result);
2969
+ }
2970
+ #paired(pending, kind, result) {
2110
2971
  this.#dropPending(pending);
2111
- const code = random2(32);
2972
+ const code = random3(32);
2112
2973
  if (this.#codes.size >= MAX_CODES)
2113
2974
  this.#sweepCodes(true);
2114
2975
  const { id: _id, expiresAt: _expires, ip: _ip, ...request } = pending;
2115
2976
  this.#codes.set(hashToken(code), {
2116
2977
  ...request,
2117
2978
  expiresAt: Date.now() + this.#authCodeTtlMs,
2118
- used: false
2979
+ used: false,
2980
+ ...result.subject === undefined ? {} : { subject: result.subject }
2119
2981
  });
2120
2982
  this.#host.logger.info("oauth.paired", {
2121
2983
  address: shortAddress(pending.addressId),
2122
2984
  client: pending.client.host,
2123
- method: method.kind
2985
+ method: kind
2124
2986
  });
2125
2987
  return this.#redirect(pending.redirectUri, {
2126
2988
  code,
@@ -2128,6 +2990,40 @@ ${errorHtml}
2128
2990
  iss: this.#issuer
2129
2991
  });
2130
2992
  }
2993
+ async authorizeCallback(req, ip) {
2994
+ if (!this.#viewsPerIp.take(ip)) {
2995
+ return this.#errorPage(429, "Too many requests from your network. Wait a minute and try again.");
2996
+ }
2997
+ const method = this.#methods.oidc;
2998
+ if (method?.callback === undefined) {
2999
+ return new Response("Not found", { status: 404 });
3000
+ }
3001
+ const browser = this.#browserId(req);
3002
+ if (browser.cookie !== undefined) {
3003
+ 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.");
3004
+ }
3005
+ this.#sweepPending();
3006
+ const params = new URL(req.url).searchParams;
3007
+ const { requestId, result } = await method.callback(params, browser.id, (id) => {
3008
+ const p = this.#pending.get(id);
3009
+ return p === undefined ? undefined : this.#host.session(p.addressId);
3010
+ });
3011
+ const pending = requestId === undefined ? undefined : this.#pending.get(requestId);
3012
+ if (pending === undefined || pending.expiresAt <= Date.now()) {
3013
+ const message = result.ok === false ? result.message : "This connection request has expired or was already used.";
3014
+ return this.#errorPage(400, message);
3015
+ }
3016
+ if (result.ok === true)
3017
+ return this.#paired(pending, method.kind, result);
3018
+ const message = result.ok === false ? result.message : "Something went wrong.";
3019
+ const reason = result.ok === false ? result.reason : "unexpected";
3020
+ this.#host.logger.info("oauth.pairing_failed", {
3021
+ address: shortAddress(pending.addressId),
3022
+ client: pending.client.host,
3023
+ reason
3024
+ });
3025
+ return this.#pairingPage(pending, browser.id, message, reason === "mismatch" ? 403 : 400);
3026
+ }
2131
3027
  #addressOf(resource) {
2132
3028
  if (resource === undefined)
2133
3029
  return;
@@ -2155,7 +3051,7 @@ ${errorHtml}
2155
3051
  return { id: v };
2156
3052
  }
2157
3053
  }
2158
- const id = random2(16);
3054
+ const id = random3(16);
2159
3055
  const secure = this.#issuer.startsWith("https:") ? "; Secure" : "";
2160
3056
  return {
2161
3057
  id,
@@ -2206,6 +3102,7 @@ ${cancel}
2206
3102
  if (method === undefined) {
2207
3103
  return this.#errorPage(500, "This page's pairing method is not available.");
2208
3104
  }
3105
+ targets.push(...method.formTargets?.() ?? []);
2209
3106
  const page = session.origin === "" ? "a page that did not say where it runs" : `<span class="origin">${escapeHtml(session.origin)}</span>`;
2210
3107
  const target = session.appName === undefined ? `tools from ${page}` : `<strong>${escapeHtml(session.appName)}</strong> on ${page}`;
2211
3108
  const heading = `Connect ${client.name} from ${client.host}`;
@@ -2328,9 +3225,12 @@ ${cancel}
2328
3225
  if (p["resource"] !== undefined && p["resource"] !== issued.resource) {
2329
3226
  throw new OAuthError(400, "invalid_target", "resource does not match the authorization request");
2330
3227
  }
2331
- if (!VERIFIER.test(verifier) || !constantTimeEqual(s256(verifier), issued.codeChallenge)) {
3228
+ if (!VERIFIER.test(verifier) || !constantTimeEqual(s2562(verifier), issued.codeChallenge)) {
2332
3229
  throw invalid("code_verifier does not match the code_challenge");
2333
3230
  }
3231
+ if (issued.subject !== undefined && this.#host.session(issued.addressId)?.identity !== issued.subject) {
3232
+ throw invalid("the page's user signed out or changed");
3233
+ }
2334
3234
  const now = Date.now();
2335
3235
  const grantId = newId("g");
2336
3236
  issued.grantId = grantId;
@@ -2341,7 +3241,8 @@ ${cancel}
2341
3241
  clientId: issued.client.clientId,
2342
3242
  clientName: issued.client.name,
2343
3243
  scopes: issued.scopes,
2344
- grantCreatedAt: now
3244
+ grantCreatedAt: now,
3245
+ ...issued.subject === undefined ? {} : { subject: issued.subject }
2345
3246
  }, {
2346
3247
  accessTtlMs: config.accessTokenTtlMs,
2347
3248
  refreshTtlMs: config.refreshTokenTtlMs,
@@ -2416,7 +3317,8 @@ ${cancel}
2416
3317
  clientId: record.clientId,
2417
3318
  clientName: record.clientName,
2418
3319
  scopes: record.scopes,
2419
- grantCreatedAt: record.grantCreatedAt
3320
+ grantCreatedAt: record.grantCreatedAt,
3321
+ ...record.subject === undefined ? {} : { subject: record.subject }
2420
3322
  }, {
2421
3323
  accessTtlMs: this.#host.config.accessTokenTtlMs,
2422
3324
  refreshTtlMs: this.#host.config.refreshTokenTtlMs,
@@ -2552,10 +3454,13 @@ class TabSession {
2552
3454
  addressId;
2553
3455
  appName;
2554
3456
  codes;
3457
+ identity;
2555
3458
  pending = new Map;
2556
3459
  #ws;
2557
3460
  #host;
2558
3461
  #state = "new";
3462
+ #dialogOpen = false;
3463
+ #identityChain = Promise.resolve();
2559
3464
  #lastRequestAt = Date.now();
2560
3465
  #missedPongs = 0;
2561
3466
  #nextRequest = 0;
@@ -2584,6 +3489,9 @@ class TabSession {
2584
3489
  get ready() {
2585
3490
  return this.#state === "ready";
2586
3491
  }
3492
+ get pairingOpen() {
3493
+ return this.site.pairing === "code" ? this.codes.active : this.#dialogOpen;
3494
+ }
2587
3495
  get limits() {
2588
3496
  return this.site.limits;
2589
3497
  }
@@ -2672,9 +3580,18 @@ class TabSession {
2672
3580
  case "pairing.start":
2673
3581
  if (this.site.pairing === "code")
2674
3582
  this.codes.start();
3583
+ else
3584
+ this.#dialogOpen = true;
2675
3585
  return;
2676
3586
  case "pairing.stop":
2677
3587
  this.codes.stop();
3588
+ this.#dialogOpen = false;
3589
+ return;
3590
+ case "identity":
3591
+ await this.#serialIdentity(() => this.#host.bindIdentity(this, msg.assertion, msg.round));
3592
+ return;
3593
+ case "identity.logout":
3594
+ await this.#serialIdentity(() => this.#host.signOut(this));
2678
3595
  return;
2679
3596
  case "grant.revoke":
2680
3597
  if (!await this.#host.revokeGrant(this, msg.grantId)) {
@@ -2754,6 +3671,7 @@ class TabSession {
2754
3671
  address: `${this.#host.publicUrl()}/a/${this.addressId}/mcp`,
2755
3672
  origin: this.origin,
2756
3673
  ...this.appName === undefined ? {} : { appName: this.appName },
3674
+ pairing: this.site.pairing,
2757
3675
  expiresAt: new Date(expiresAt).toISOString(),
2758
3676
  limits: limitsOf(limits),
2759
3677
  grants
@@ -2773,6 +3691,11 @@ class TabSession {
2773
3691
  });
2774
3692
  this.#host.ready?.(this);
2775
3693
  }
3694
+ #serialIdentity(run) {
3695
+ const next = this.#identityChain.then(run);
3696
+ this.#identityChain = next.catch(() => {});
3697
+ return next;
3698
+ }
2776
3699
  #tick() {
2777
3700
  if (this.#state !== "ready")
2778
3701
  return;
@@ -2847,6 +3770,7 @@ class TabSession {
2847
3770
  const wasReady = this.#state === "ready" || this.#state === "hello";
2848
3771
  this.#state = "ended";
2849
3772
  this.codes.stop();
3773
+ this.#dialogOpen = false;
2850
3774
  clearTimeout(this.#helloTimer);
2851
3775
  clearInterval(this.#heartbeatTimer);
2852
3776
  clearTimeout(this.#lifetimeTimer);
@@ -2886,13 +3810,16 @@ function summarizeGrants(records, now) {
2886
3810
  for (const r of records) {
2887
3811
  if (!isLive(r, now) || grants.has(r.grantId))
2888
3812
  continue;
2889
- grants.set(r.grantId, {
3813
+ const summary = {
2890
3814
  grantId: r.grantId,
2891
3815
  clientId: r.clientId,
2892
3816
  clientName: r.clientName,
2893
3817
  scopes: [...r.scopes],
2894
3818
  createdAt: r.grantCreatedAt
2895
- });
3819
+ };
3820
+ if (r.subject !== undefined)
3821
+ summary.subject = r.subject;
3822
+ grants.set(r.grantId, summary);
2896
3823
  }
2897
3824
  return [...grants.values()].sort((a, b) => a.createdAt - b.createdAt || a.grantId.localeCompare(b.grantId));
2898
3825
  }
@@ -2972,7 +3899,7 @@ class MemoryTokenStore {
2972
3899
 
2973
3900
  // src/store/sqlite.ts
2974
3901
  import { Database } from "bun:sqlite";
2975
- var SCHEMA_VERSION = 2;
3902
+ var SCHEMA_VERSION = 3;
2976
3903
  function toRecord(row) {
2977
3904
  const record = {
2978
3905
  hash: row.hash,
@@ -2988,6 +3915,8 @@ function toRecord(row) {
2988
3915
  };
2989
3916
  if (row.used_at !== null)
2990
3917
  record.usedAt = row.used_at;
3918
+ if (row.subject !== null)
3919
+ record.subject = row.subject;
2991
3920
  return record;
2992
3921
  }
2993
3922
 
@@ -3023,8 +3952,13 @@ class SqliteTokenStore {
3023
3952
  grant_created_at INTEGER NOT NULL,
3024
3953
  issued_at INTEGER NOT NULL,
3025
3954
  expires_at INTEGER NOT NULL,
3026
- used_at INTEGER
3955
+ used_at INTEGER,
3956
+ subject TEXT
3027
3957
  ) STRICT`);
3958
+ const columns = this.#db.query("PRAGMA table_info(tokens)").all();
3959
+ if (!columns.some((c) => c.name === "subject")) {
3960
+ this.#db.run("ALTER TABLE tokens ADD COLUMN subject TEXT");
3961
+ }
3028
3962
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_grant ON tokens (grant_id)");
3029
3963
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_address ON tokens (address_id)");
3030
3964
  this.#db.run("CREATE INDEX IF NOT EXISTS tokens_expires ON tokens (expires_at)");
@@ -3047,9 +3981,9 @@ class SqliteTokenStore {
3047
3981
  }
3048
3982
  #insert(r) {
3049
3983
  this.#db.query(`INSERT OR REPLACE INTO tokens (hash, kind, grant_id, address_id, client_id,
3050
- client_name, scopes, grant_created_at, issued_at, expires_at, used_at)
3984
+ client_name, scopes, grant_created_at, issued_at, expires_at, used_at, subject)
3051
3985
  VALUES ($hash, $kind, $grant_id, $address_id, $client_id, $client_name,
3052
- $scopes, $grant_created_at, $issued_at, $expires_at, $used_at)`).run({
3986
+ $scopes, $grant_created_at, $issued_at, $expires_at, $used_at, $subject)`).run({
3053
3987
  hash: r.hash,
3054
3988
  kind: r.kind,
3055
3989
  grant_id: r.grantId,
@@ -3060,7 +3994,8 @@ class SqliteTokenStore {
3060
3994
  grant_created_at: r.grantCreatedAt,
3061
3995
  issued_at: r.issuedAt,
3062
3996
  expires_at: r.expiresAt,
3063
- used_at: r.usedAt ?? null
3997
+ used_at: r.usedAt ?? null,
3998
+ subject: r.subject ?? null
3064
3999
  });
3065
4000
  }
3066
4001
  async get(hash) {
@@ -3171,6 +4106,7 @@ function startRelay(options, hooks) {
3171
4106
  const appNames = new Map;
3172
4107
  let claudeWorkarounds = 0;
3173
4108
  let publicUrl = config.publicUrl ?? "";
4109
+ const assertions = config.oidc === undefined ? undefined : new TabAssertionVerifier(config.oidc, hooks?.assertionKeys);
3174
4110
  const corsHeaders = config.cors ? {
3175
4111
  "access-control-allow-origin": "*",
3176
4112
  "access-control-expose-headers": "www-authenticate, mcp-protocol-version"
@@ -3218,6 +4154,105 @@ function startRelay(options, hooks) {
3218
4154
  for (const g of grants)
3219
4155
  grantRemoved(addressId, g.grantId, "revoked");
3220
4156
  },
4157
+ async bindIdentity(session, assertion, round) {
4158
+ const addressId = session.addressId;
4159
+ if (addressId === undefined)
4160
+ return;
4161
+ const echo = round === undefined ? {} : { round };
4162
+ const answer = (message) => {
4163
+ if (!session.send(message)) {
4164
+ logger.warn("identity.answer_dropped", {
4165
+ address: shortAddress(addressId),
4166
+ type: message.type
4167
+ });
4168
+ }
4169
+ };
4170
+ const refuse = (reason, message) => {
4171
+ if (session.identity !== undefined) {
4172
+ session.identity = undefined;
4173
+ failPending(session, "The page's user is no longer signed in.");
4174
+ }
4175
+ logger.info("identity.refused", {
4176
+ address: shortAddress(addressId),
4177
+ reason
4178
+ });
4179
+ answer({ type: "error", code: "identity_refused", message, ...echo });
4180
+ };
4181
+ if (session.site.pairing !== "oidc" || assertions === undefined) {
4182
+ refuse("not_oidc", "this page's site does not use identity binding");
4183
+ return;
4184
+ }
4185
+ let key;
4186
+ try {
4187
+ key = await assertions.verify(assertion, `${publicUrl}/a/${addressId}/mcp`);
4188
+ } catch (error) {
4189
+ refuse(error instanceof JwtError ? error.reason : "error", `the identity assertion was refused: ${error instanceof JwtError ? error.message : "the relay could not check it"}`);
4190
+ return;
4191
+ }
4192
+ if (!session.ready)
4193
+ return;
4194
+ const changed = session.identity !== key;
4195
+ if (changed && session.identity !== undefined) {
4196
+ session.identity = undefined;
4197
+ failPending(session, "The page's user changed.");
4198
+ }
4199
+ let revoked = 0;
4200
+ try {
4201
+ const grants = await store.listGrants(addressId, Date.now());
4202
+ for (const g of grants.filter((g) => g.subject !== key)) {
4203
+ await store.revoke({ grantId: g.grantId }, revokeOptions());
4204
+ grantRemoved(addressId, g.grantId, "revoked");
4205
+ revoked++;
4206
+ }
4207
+ } catch (error) {
4208
+ logger.error("store.error", {
4209
+ op: "identity",
4210
+ message: error instanceof Error ? error.message : "unknown"
4211
+ });
4212
+ refuse("store_unavailable", "the relay cannot check grants right now");
4213
+ return;
4214
+ }
4215
+ if (!session.ready)
4216
+ return;
4217
+ session.identity = key;
4218
+ answer({ type: "identity.bound", ...echo });
4219
+ logger.info("identity.bound", {
4220
+ address: shortAddress(addressId),
4221
+ changed,
4222
+ revoked
4223
+ });
4224
+ host.ready(session);
4225
+ },
4226
+ async signOut(session) {
4227
+ const addressId = session.addressId;
4228
+ if (addressId === undefined)
4229
+ return;
4230
+ if (session.site.pairing !== "oidc") {
4231
+ session.send({
4232
+ type: "error",
4233
+ code: "identity_refused",
4234
+ message: "this page's site does not use identity binding"
4235
+ });
4236
+ return;
4237
+ }
4238
+ session.identity = undefined;
4239
+ failPending(session, "The page's user signed out.");
4240
+ try {
4241
+ const grants = await store.listGrants(addressId, Date.now());
4242
+ await store.revoke({ addressId }, revokeOptions());
4243
+ logger.info("identity.signed_out", {
4244
+ address: shortAddress(addressId),
4245
+ grants: grants.length
4246
+ });
4247
+ for (const g of grants)
4248
+ grantRemoved(addressId, g.grantId, "revoked");
4249
+ } catch (error) {
4250
+ logger.error("store.error", {
4251
+ op: "signOut",
4252
+ message: error instanceof Error ? error.message : "unknown"
4253
+ });
4254
+ }
4255
+ },
3221
4256
  claimAddress(session) {
3222
4257
  const id = session.addressId;
3223
4258
  if (id === undefined)
@@ -3252,12 +4287,20 @@ function startRelay(options, hooks) {
3252
4287
  wake(session);
3253
4288
  }
3254
4289
  };
3255
- function readySession(addressId) {
4290
+ function failPending(session, message) {
4291
+ for (const exchange of [...session.pending.values()]) {
4292
+ exchange.fail(401, RelayErrorCode.signedOut, message, true);
4293
+ }
4294
+ }
4295
+ function readySession(addressId, usable = () => true) {
3256
4296
  const s = sessions.get(addressId);
3257
- return s?.ready === true ? s : undefined;
4297
+ return s?.ready === true && usable(s) ? s : undefined;
3258
4298
  }
3259
- function waitForPage(addressId, signal, waitMs) {
3260
- const now = readySession(addressId);
4299
+ function usableFor(subject) {
4300
+ return (s) => subject === undefined && s.site.pairing === "code" || s.identity !== undefined;
4301
+ }
4302
+ function waitForPage(addressId, signal, waitMs, usable) {
4303
+ const now = readySession(addressId, usable);
3261
4304
  if (now !== undefined)
3262
4305
  return Promise.resolve(now);
3263
4306
  if (waitMs === 0 || stopping !== undefined) {
@@ -3283,7 +4326,10 @@ function startRelay(options, hooks) {
3283
4326
  }
3284
4327
  resolve(result);
3285
4328
  };
3286
- const wake = (session) => done(session);
4329
+ const wake = (session) => {
4330
+ if (session === undefined || usable(session))
4331
+ done(session);
4332
+ };
3287
4333
  const onAbort = () => done("aborted");
3288
4334
  set.add(wake);
3289
4335
  waiters.set(addressId, set);
@@ -3517,18 +4563,26 @@ function startRelay(options, hooks) {
3517
4563
  logDone(499, method, { outcome: "client_closed" });
3518
4564
  return new Response(null, { status: 499 });
3519
4565
  };
3520
- if (readySession(addressId) === undefined && waitMs > 0) {
4566
+ const usable = usableFor(auth.subject);
4567
+ if (readySession(addressId, usable) === undefined && waitMs > 0) {
3521
4568
  server.timeout(req, 0);
3522
4569
  }
3523
4570
  let retried = false;
3524
4571
  for (;; ) {
3525
- const found = await waitForPage(addressId, req.signal, waitMs);
4572
+ const found = await waitForPage(addressId, req.signal, waitMs, usable);
3526
4573
  if (found === "aborted" || req.signal.aborted)
3527
4574
  return closed();
3528
4575
  if (found === undefined) {
3529
4576
  return pageNotOpen(body.value, rpcId, addressId, logDone, method);
3530
4577
  }
3531
4578
  const session = found;
4579
+ if ((auth.subject !== undefined || session.site.pairing === "oidc") && session.identity !== auth.subject) {
4580
+ logDone(401, method, { reason: "identity_mismatch" });
4581
+ return json(401, { error: "invalid_token", error_description: MISMATCH_TEXT }, {
4582
+ ...corsHeaders,
4583
+ "www-authenticate": `${wwwAuthenticate(addressId, true)}, error_description="${MISMATCH_TEXT}"`
4584
+ });
4585
+ }
3532
4586
  if (Buffer.byteLength(body.text) > session.limits.maxMessageBytes) {
3533
4587
  logDone(413, method);
3534
4588
  return rpcError(413, rpcId, -32600, "Request body too large.", corsHeaders);
@@ -3584,7 +4638,9 @@ function startRelay(options, hooks) {
3584
4638
  }
3585
4639
  }
3586
4640
  function pageNotOpen(value, rpcId, addressId, logDone, method) {
3587
- const answer = notOpenAnswer(value, rpcId, appNames.get(addressId), "not_open", RELAY_INFO);
4641
+ const open = sessions.get(addressId);
4642
+ const phase = open?.ready === true && open.identity === undefined && open.site.pairing === "oidc" ? "signed_out" : "not_open";
4643
+ const answer = notOpenAnswer(value, rpcId, open?.appName ?? appNames.get(addressId), phase, RELAY_INFO);
3588
4644
  if (answer === undefined) {
3589
4645
  logDone(503, method, { reason: "page_not_open" });
3590
4646
  return rpcError(503, rpcId, RelayErrorCode.pageNotOpen, "The page is not open.", corsHeaders);
@@ -3628,14 +4684,15 @@ function startRelay(options, hooks) {
3628
4684
  status: "ok",
3629
4685
  version: VERSION,
3630
4686
  counters: { claudeHeaderWorkaround: claudeWorkarounds }
3631
- })
4687
+ }, { "cache-control": "no-store" })
3632
4688
  },
3633
4689
  "/": {
3634
4690
  GET: () => new Response(INDEX_HTML, {
3635
4691
  headers: {
3636
4692
  "content-type": "text/html; charset=utf-8",
3637
4693
  "content-security-policy": "default-src 'none'; frame-ancestors 'none'",
3638
- "x-content-type-options": "nosniff"
4694
+ "x-content-type-options": "nosniff",
4695
+ "cache-control": "public, max-age=3600"
3639
4696
  }
3640
4697
  })
3641
4698
  },
@@ -3652,6 +4709,9 @@ function startRelay(options, hooks) {
3652
4709
  GET: (req, srv) => auth.authorize(req, ipOf(req, srv)),
3653
4710
  POST: (req, srv) => auth.authorizeSubmit(req, ipOf(req, srv))
3654
4711
  },
4712
+ "/authorize/callback": {
4713
+ GET: (req, srv) => auth.authorizeCallback(req, ipOf(req, srv))
4714
+ },
3655
4715
  "/authorize.css": { GET: () => auth.stylesheet() },
3656
4716
  "/token": {
3657
4717
  POST: (req, srv) => auth.token(req, ipOf(req, srv)),
@@ -3764,6 +4824,7 @@ export {
3764
4824
  MemoryTokenStore,
3765
4825
  SUBPROTOCOL,
3766
4826
  SqliteTokenStore,
4827
+ TAB_ASSERTION_TYP,
3767
4828
  VERSION,
3768
4829
  addressIdFromSecret,
3769
4830
  configFromEnv,
@@ -3773,5 +4834,5 @@ export {
3773
4834
  resolveConfig
3774
4835
  };
3775
4836
 
3776
- //# debugId=50BF7EDE9EC7441F64756E2164756E21
4837
+ //# debugId=D6651982449E84CF64756E2164756E21
3777
4838
  //# sourceMappingURL=index.js.map